kanbo-cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +2 -0
- package/dist/cli-main.cjs +1347 -254
- package/dist/drizzle-postgres/0006_flow_rules_and_column_entries.sql +3 -0
- package/dist/drizzle-postgres/README.md +8 -0
- package/dist/drizzle-postgres/meta/0006_snapshot.json +1290 -0
- package/dist/drizzle-postgres/meta/_journal.json +7 -0
- package/dist/drizzle-sqlite/0005_flow_rules_and_column_entries.sql +3 -0
- package/dist/drizzle-sqlite/README.md +8 -0
- package/dist/drizzle-sqlite/meta/0005_snapshot.json +1097 -0
- package/dist/drizzle-sqlite/meta/_journal.json +7 -0
- package/dist/lib/{approval-BsBk2xVr.d.cts → approval-CnJJfwfi.d.mts} +30 -6
- package/dist/lib/{approval-LRZ6ontC.d.mts → approval-DNfUhYD5.d.cts} +30 -6
- package/dist/lib/{board-store-D_Mpfb84.d.mts → board-store-D2x2w1wG.d.cts} +26 -2
- package/dist/lib/{board-store-Dx-mw2I6.d.cts → board-store-DN5Y2U2y.d.mts} +26 -2
- package/dist/lib/index.cjs +3 -1
- package/dist/lib/index.d.cts +96 -6
- package/dist/lib/index.d.mts +96 -6
- package/dist/lib/index.mjs +2 -2
- package/dist/lib/{manifest-QPoQl8tn.cjs → manifest-DUGRyC7A.cjs} +1378 -300
- package/dist/lib/{manifest-v2e9x_C8.mjs → manifest-t4LvFgzH.mjs} +1369 -303
- package/dist/lib/mcp/index.cjs +35 -6
- package/dist/lib/mcp/index.d.cts +23 -4
- package/dist/lib/mcp/index.d.mts +23 -4
- package/dist/lib/mcp/index.mjs +35 -6
- package/dist/lib/{migrate-DtBeW3Fs.mjs → migrate-BUsaTJlj.mjs} +8 -1
- package/dist/lib/{migrate-Dca5WdYd.cjs → migrate-BoGrqK9m.cjs} +7 -0
- package/dist/lib/postgres/index.cjs +1 -1
- package/dist/lib/postgres/index.d.cts +69 -1
- package/dist/lib/postgres/index.d.mts +69 -1
- package/dist/lib/postgres/index.mjs +1 -1
- package/dist/lib/{roles.sql-BoOZSkBp.mjs → roles.sql-BDmmNUdx.mjs} +18 -2
- package/dist/lib/{roles.sql-E58x7mLS.cjs → roles.sql-CBFxSHy7.cjs} +17 -1
- package/dist/lib/{schema-D0MyXcej.d.cts → schema-Cwx3HosC.d.cts} +72 -0
- package/dist/lib/{schema-D0MyXcej.d.mts → schema-Cwx3HosC.d.mts} +72 -0
- package/dist/lib/sqlite/index.cjs +1 -1
- package/dist/lib/sqlite/index.d.cts +2 -2
- package/dist/lib/sqlite/index.d.mts +2 -2
- package/dist/lib/sqlite/index.mjs +1 -1
- package/dist/lib/sqlite/schema.cjs +13 -1
- package/dist/lib/sqlite/schema.d.cts +1 -1
- package/dist/lib/sqlite/schema.d.mts +1 -1
- package/dist/lib/sqlite/schema.mjs +13 -1
- package/dist/page/board.css +1622 -327
- package/dist/page/board.js +2 -2
- package/package.json +1 -1
package/dist/cli-main.cjs
CHANGED
|
@@ -272,6 +272,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
272
272
|
text: "Only a person can approve a card.",
|
|
273
273
|
next: AS_A_PERSON
|
|
274
274
|
}),
|
|
275
|
+
board_card_not_ready: (details) => ({
|
|
276
|
+
text: details.reason === "run_active" ? `${named(details.issueId)} is taken: it has a run going.` : details.reason === "waiting_for_person" ? `${named(details.issueId)} is waiting for a person.` : `${named(details.issueId)} is not in To Do, so it is not there to take.`,
|
|
277
|
+
next: "kanbo ready"
|
|
278
|
+
}),
|
|
279
|
+
board_card_run_active: (details) => ({
|
|
280
|
+
text: `${named(details.issueId)} already has a run going: ${named(details.agentName)}'s, run ${named(details.runId)}.`,
|
|
281
|
+
next: `kanbo run finish ${named(details.runId)} --state stopped`
|
|
282
|
+
}),
|
|
275
283
|
board_column_name_taken: (details) => ({
|
|
276
284
|
text: `Another column, ${named(details.takenBy)}, already has that name (same slug). Pick another name.`,
|
|
277
285
|
next: "kanbo columns list"
|
|
@@ -302,6 +310,14 @@ const BOARD_ERROR_TEXTS = {
|
|
|
302
310
|
}),
|
|
303
311
|
board_entry_rule_invalid: (details) => ({ text: `Unknown column rule: ${list(details.rules)}. Use any of ${list(details.allowed)}.` }),
|
|
304
312
|
board_execution_mode_locked: (details) => ({ text: `${named(details.issueId)} has been started, so where it is worked (${named(details.executionMode)}) can't change now.` }),
|
|
313
|
+
board_flow_rule_invalid: (details) => details.wipLimit !== void 0 ? { text: `A column holds at least 1 card; ${named(details.wipLimit)} is not a limit. Use none for no limit.` } : {
|
|
314
|
+
text: `A column accepts cards only from other columns of its own board, and ${list(details.allowedFrom)} is not one.`,
|
|
315
|
+
next: "kanbo columns list"
|
|
316
|
+
},
|
|
317
|
+
board_nothing_ready: () => ({
|
|
318
|
+
text: "No card in To Do is ready to take: each one is taken or waits for a person, or there is none.",
|
|
319
|
+
next: "kanbo ready"
|
|
320
|
+
}),
|
|
305
321
|
board_pull_request_invalid: (details) => ({ text: `"${named(details.value)}" is not a GitHub pull request. Give its link or owner/repo#number.` }),
|
|
306
322
|
board_pull_request_not_found: (details) => ({
|
|
307
323
|
text: `${named(details.issueId)} has no pull request link "${named(details.linkId)}".`,
|
|
@@ -357,6 +373,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
357
373
|
text: `No card "${named(details.issueId)}" here.`,
|
|
358
374
|
next: "kanbo card list"
|
|
359
375
|
}),
|
|
376
|
+
issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
|
|
360
377
|
issue_parent_not_found: (details) => ({
|
|
361
378
|
text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
|
|
362
379
|
next: "kanbo card list"
|
|
@@ -366,7 +383,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
366
383
|
issue_relation_self_reference: () => ({ text: "A card can't be linked to itself." }),
|
|
367
384
|
issue_status_name_empty: () => ({ text: "Give the column a name." }),
|
|
368
385
|
issue_status_not_found: (details) => ({
|
|
369
|
-
text: `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
386
|
+
text: details.category !== void 0 ? `This board has no ${named(details.category)} column for a claimed card to go to.` : `No column "${named(details.statusName ?? details.statusId)}" on this board.`,
|
|
370
387
|
next: "kanbo columns list"
|
|
371
388
|
}),
|
|
372
389
|
issue_status_reference_conflict: () => ({ text: "Name the column once: by its id or by its name, not both." }),
|
|
@@ -3560,7 +3577,7 @@ new Command();
|
|
|
3560
3577
|
* `package.version`. A build carries it as a literal; running from source
|
|
3561
3578
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
3562
3579
|
*/
|
|
3563
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
3580
|
+
const KANBO_PACKAGE_VERSION = "0.5.0";
|
|
3564
3581
|
//#endregion
|
|
3565
3582
|
//#region src/cli/agent-shell.ts
|
|
3566
3583
|
/**
|
|
@@ -3595,22 +3612,26 @@ const AGENT_SHELL_MARKERS = [
|
|
|
3595
3612
|
{
|
|
3596
3613
|
variable: "CLAUDECODE",
|
|
3597
3614
|
matches: (value) => value === "1",
|
|
3598
|
-
label: "CLAUDECODE=1"
|
|
3615
|
+
label: "CLAUDECODE=1",
|
|
3616
|
+
tool: "claude"
|
|
3599
3617
|
},
|
|
3600
3618
|
{
|
|
3601
3619
|
variable: "GEMINI_CLI",
|
|
3602
3620
|
matches: (value) => value === "1",
|
|
3603
|
-
label: "GEMINI_CLI=1"
|
|
3621
|
+
label: "GEMINI_CLI=1",
|
|
3622
|
+
tool: "gemini"
|
|
3604
3623
|
},
|
|
3605
3624
|
{
|
|
3606
3625
|
variable: "CURSOR_AGENT",
|
|
3607
3626
|
matches: (value) => value !== "",
|
|
3608
|
-
label: "CURSOR_AGENT"
|
|
3627
|
+
label: "CURSOR_AGENT",
|
|
3628
|
+
tool: "cursor"
|
|
3609
3629
|
},
|
|
3610
3630
|
{
|
|
3611
3631
|
variable: "CODEX_THREAD_ID",
|
|
3612
3632
|
matches: (value) => value !== "",
|
|
3613
|
-
label: "CODEX_THREAD_ID"
|
|
3633
|
+
label: "CODEX_THREAD_ID",
|
|
3634
|
+
tool: "codex"
|
|
3614
3635
|
}
|
|
3615
3636
|
];
|
|
3616
3637
|
/**
|
|
@@ -3627,6 +3648,15 @@ function readAgentShellMarker(env = process.env) {
|
|
|
3627
3648
|
if (kind === "person") return null;
|
|
3628
3649
|
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
|
|
3629
3650
|
}
|
|
3651
|
+
/**
|
|
3652
|
+
* The name of the agent tool this shell belongs to (`claude`, `gemini`,
|
|
3653
|
+
* `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
|
|
3654
|
+
* `null` for a person's shell. Same precedence as `readAgentShellMarker`.
|
|
3655
|
+
*/
|
|
3656
|
+
function readAgentTool(env = process.env) {
|
|
3657
|
+
if (readAgentShellMarker(env) === null) return null;
|
|
3658
|
+
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
|
|
3659
|
+
}
|
|
3630
3660
|
//#endregion
|
|
3631
3661
|
//#region src/cli/actor.ts
|
|
3632
3662
|
/**
|
|
@@ -3686,6 +3716,7 @@ const HUMAN_ONLY_ACTIONS = [
|
|
|
3686
3716
|
"return",
|
|
3687
3717
|
"sprint close",
|
|
3688
3718
|
"columns rules",
|
|
3719
|
+
"columns flow",
|
|
3689
3720
|
"columns add",
|
|
3690
3721
|
"columns rename",
|
|
3691
3722
|
"columns move",
|
|
@@ -3699,6 +3730,7 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
3699
3730
|
"return": describeReturnRefusal,
|
|
3700
3731
|
"sprint close": describeSprintCloseRefusal,
|
|
3701
3732
|
"columns rules": describeColumnRulesRefusal,
|
|
3733
|
+
"columns flow": describeColumnRulesRefusal,
|
|
3702
3734
|
"columns add": describeColumnChangesRefusal,
|
|
3703
3735
|
"columns rename": describeColumnChangesRefusal,
|
|
3704
3736
|
"columns move": describeColumnChangesRefusal,
|
|
@@ -3708,11 +3740,15 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
3708
3740
|
"run clear-session": describeRunSessionRefRefusal,
|
|
3709
3741
|
"run attach-session --replace": describeRunSessionRefRefusal
|
|
3710
3742
|
};
|
|
3711
|
-
/**
|
|
3743
|
+
/**
|
|
3744
|
+
* The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
|
|
3745
|
+
* else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
|
|
3746
|
+
* operating system's user.
|
|
3747
|
+
*/
|
|
3712
3748
|
function createCliActor() {
|
|
3713
3749
|
return {
|
|
3714
3750
|
kind: "external",
|
|
3715
|
-
id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
|
|
3751
|
+
id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
|
|
3716
3752
|
name: (0, node_os.hostname)()
|
|
3717
3753
|
};
|
|
3718
3754
|
}
|
|
@@ -3750,6 +3786,14 @@ function createPlacementActor() {
|
|
|
3750
3786
|
placesForPerson: true
|
|
3751
3787
|
};
|
|
3752
3788
|
}
|
|
3789
|
+
/** Who the board page writes as: the person at this machine — `KANBO_ACTOR_ID` in a person's shell, else the OS user; never an agent tool, since the page is read and typed in by a person. */
|
|
3790
|
+
function createServeWriter() {
|
|
3791
|
+
return {
|
|
3792
|
+
kind: "external",
|
|
3793
|
+
id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
|
|
3794
|
+
name: (0, node_os.hostname)()
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3753
3797
|
/**
|
|
3754
3798
|
* The person who owns this shell, as the board files a person. Only for a
|
|
3755
3799
|
* caller that has already asked `isAgentShell` — `requireHumanActor` for a
|
|
@@ -9988,6 +10032,14 @@ const HIDDEN_ACTIVITY_FIELDS = /* @__PURE__ */ new Set([
|
|
|
9988
10032
|
"order",
|
|
9989
10033
|
"waitingFor"
|
|
9990
10034
|
]);
|
|
10035
|
+
/**
|
|
10036
|
+
* The column a card was created in, recorded as a change from no column at the
|
|
10037
|
+
* card's own creation time. The feed's `created` item already stands for it,
|
|
10038
|
+
* and the field history keeps it.
|
|
10039
|
+
*/
|
|
10040
|
+
function isStartingColumn(change, issue) {
|
|
10041
|
+
return change.field === "statusId" && change.fromValue === null && change.createdAt === issue.createdAt;
|
|
10042
|
+
}
|
|
9991
10043
|
const ACTIVITY_FIELD_BY_RAW_FIELD = {
|
|
9992
10044
|
assigneeId: "assignee",
|
|
9993
10045
|
description: "description",
|
|
@@ -10028,7 +10080,7 @@ async function listActivity(store, issueId, resolver) {
|
|
|
10028
10080
|
workspaceById: resolver.workspaceById
|
|
10029
10081
|
};
|
|
10030
10082
|
const commentItems = await Promise.all((await store.comments.listByIssue(issueId)).map(async (comment) => await toCommentActivityItem(comment, resolver)));
|
|
10031
|
-
const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
|
|
10083
|
+
const fieldChangeItems = (await Promise.all((await store.fieldChanges.listByIssue(issueId)).filter((change) => !isStartingColumn(change, issue)).map(async (change) => await toFieldChangeActivityItem(change, lookup, resolver)))).filter((item) => item !== null);
|
|
10032
10084
|
return [
|
|
10033
10085
|
{
|
|
10034
10086
|
id: `${issue.id}:created`,
|
|
@@ -10652,6 +10704,63 @@ function describeColumnRefusal(details) {
|
|
|
10652
10704
|
return [`board_column_rules_unmet: ${details.issueId ? `${details.issueId} cannot enter` : "A new card cannot be created in"} "${details.column}" yet:`, ...details.unmet.map((item) => `- ${item.rule}: ${item.detail}`)].join("\n");
|
|
10653
10705
|
}
|
|
10654
10706
|
//#endregion
|
|
10707
|
+
//#region src/domain/flow-rules.ts
|
|
10708
|
+
/** The column the standard flow guards, and the columns it lets cards in from, by slug. */
|
|
10709
|
+
const STANDARD_FLOW_TARGET = "done";
|
|
10710
|
+
const STANDARD_FLOW_SOURCES = ["in_review", "qa"];
|
|
10711
|
+
/** A column's flow rules, out of what the row stores. */
|
|
10712
|
+
function readFlowRules(column) {
|
|
10713
|
+
return {
|
|
10714
|
+
allowedFrom: column.allowedFrom === null ? null : JSON.parse(column.allowedFrom),
|
|
10715
|
+
wipLimit: column.wipLimit
|
|
10716
|
+
};
|
|
10717
|
+
}
|
|
10718
|
+
/** A list of column ids as the row stores it: `null` stays `null`, and an empty list stays an empty list. */
|
|
10719
|
+
function serializeAllowedFrom(allowedFrom) {
|
|
10720
|
+
return allowedFrom === null ? null : JSON.stringify(allowedFrom);
|
|
10721
|
+
}
|
|
10722
|
+
/**
|
|
10723
|
+
* The flow rules a card entering `column` does not meet, where it may come from
|
|
10724
|
+
* first. Pure: what the board holds arrives in `facts`. A card being created
|
|
10725
|
+
* comes from no column, so a column that names where cards come from does not
|
|
10726
|
+
* take it.
|
|
10727
|
+
*/
|
|
10728
|
+
function evaluateFlowRules(card, column, rules, facts) {
|
|
10729
|
+
const unmet = [];
|
|
10730
|
+
if (rules.allowedFrom !== null && (card.statusId === null || !rules.allowedFrom.includes(card.statusId))) unmet.push({
|
|
10731
|
+
rule: "allowed_from",
|
|
10732
|
+
reason: "not_allowed_from",
|
|
10733
|
+
detail: describeAllowedFrom(column.name, rules.allowedFrom, facts.columnNames)
|
|
10734
|
+
});
|
|
10735
|
+
if (rules.wipLimit !== null && facts.cardsInColumn >= rules.wipLimit) unmet.push({
|
|
10736
|
+
rule: "wip_limit",
|
|
10737
|
+
reason: "column_full",
|
|
10738
|
+
detail: `${column.name} holds ${facts.cardsInColumn} of ${rules.wipLimit}`
|
|
10739
|
+
});
|
|
10740
|
+
return unmet;
|
|
10741
|
+
}
|
|
10742
|
+
function describeAllowedFrom(name, allowedFrom, columnNames) {
|
|
10743
|
+
const names = allowedFrom.map((id) => columnNames.get(id)).filter((found) => found !== void 0);
|
|
10744
|
+
return names.length === 0 ? `${name} accepts cards from no column; only a person may put a card here` : `${name} accepts cards from ${joinWords(names)} only`;
|
|
10745
|
+
}
|
|
10746
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
10747
|
+
function joinWords(words) {
|
|
10748
|
+
return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
10749
|
+
}
|
|
10750
|
+
/**
|
|
10751
|
+
* The standard flow on these columns: Done, and the columns it takes cards from
|
|
10752
|
+
* — In Review, and QA when there is one — in board order. `null` when the board
|
|
10753
|
+
* has no Done, or neither of the two.
|
|
10754
|
+
*/
|
|
10755
|
+
function standardFlowOf(columns) {
|
|
10756
|
+
const target = columns.find((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET);
|
|
10757
|
+
const sources = columns.filter((column) => STANDARD_FLOW_SOURCES.includes(normalizeStatusName(column.name)));
|
|
10758
|
+
return target && sources.length > 0 ? {
|
|
10759
|
+
target,
|
|
10760
|
+
sources
|
|
10761
|
+
} : null;
|
|
10762
|
+
}
|
|
10763
|
+
//#endregion
|
|
10655
10764
|
//#region src/ops/columns.ts
|
|
10656
10765
|
/** The columns of one workspace, in board order. */
|
|
10657
10766
|
async function listColumns(store, workspaceId) {
|
|
@@ -10721,6 +10830,7 @@ async function applyColumnTemplate(store, workspaceId, columns, options, scope)
|
|
|
10721
10830
|
workspaceId,
|
|
10722
10831
|
...column
|
|
10723
10832
|
}, order);
|
|
10833
|
+
await writeStandardFlow(tx, await listColumns(tx, workspaceId));
|
|
10724
10834
|
return {
|
|
10725
10835
|
statuses: await listColumns(tx, workspaceId),
|
|
10726
10836
|
added: columns.map((column) => column.name)
|
|
@@ -10830,6 +10940,77 @@ async function setColumnEntryRules(store, workspaceId, nameOrId, entryRules, act
|
|
|
10830
10940
|
return await updateColumn(tx, (await requireColumn(tx, workspaceId, nameOrId)).id, { entryRules }, actor, { tx });
|
|
10831
10941
|
}, scope);
|
|
10832
10942
|
}
|
|
10943
|
+
/** The largest WIP limit: what a Postgres `integer` column holds. */
|
|
10944
|
+
const MAX_WIP_LIMIT = 2147483647;
|
|
10945
|
+
/**
|
|
10946
|
+
* Set where a card may enter the column from and how many cards it holds:
|
|
10947
|
+
* `allowedFrom` the ids of other columns of its board (`null` for any, `[]` for
|
|
10948
|
+
* none), kept once each in board order; `wipLimit` a whole number from 1 to 2147483647, or `null`. A
|
|
10949
|
+
* person's to set, like the entry rules: an agent that could open a column
|
|
10950
|
+
* could walk any card into it.
|
|
10951
|
+
*/
|
|
10952
|
+
async function setColumnFlowRules(store, columnId, rules, actor, scope) {
|
|
10953
|
+
assertPersonSetsEntryRules(actor, { statusId: columnId });
|
|
10954
|
+
if (rules.wipLimit !== null && !(Number.isSafeInteger(rules.wipLimit) && rules.wipLimit >= 1 && rules.wipLimit <= MAX_WIP_LIMIT)) throw new BoardError("board_flow_rule_invalid", {
|
|
10955
|
+
statusId: columnId,
|
|
10956
|
+
wipLimit: rules.wipLimit
|
|
10957
|
+
});
|
|
10958
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
10959
|
+
const column = await tx.statuses.findById(columnId);
|
|
10960
|
+
if (!column) throw new BoardError("issue_status_not_found", { statusId: columnId });
|
|
10961
|
+
const allowedFrom = rules.allowedFrom === null ? null : allowedFromInBoardOrder(await listColumns(tx, column.workspaceId), column, rules.allowedFrom);
|
|
10962
|
+
await tx.statuses.update(columnId, {
|
|
10963
|
+
allowedFrom: serializeAllowedFrom(allowedFrom),
|
|
10964
|
+
wipLimit: rules.wipLimit
|
|
10965
|
+
});
|
|
10966
|
+
return await tx.statuses.findById(columnId);
|
|
10967
|
+
}, scope);
|
|
10968
|
+
}
|
|
10969
|
+
/** The ids asked for, once each and in board order — or the refusal naming those that are not another column of this board. */
|
|
10970
|
+
function allowedFromInBoardOrder(columns, column, requested) {
|
|
10971
|
+
const others = columns.filter((other) => other.id !== column.id);
|
|
10972
|
+
const invalid = requested.filter((id) => !others.some((other) => other.id === id));
|
|
10973
|
+
if (invalid.length > 0) throw new BoardError("board_flow_rule_invalid", {
|
|
10974
|
+
statusId: column.id,
|
|
10975
|
+
allowedFrom: invalid
|
|
10976
|
+
});
|
|
10977
|
+
return others.filter((other) => requested.includes(other.id)).map((other) => other.id);
|
|
10978
|
+
}
|
|
10979
|
+
/**
|
|
10980
|
+
* Put the standard flow on a board: Done accepts cards from In Review only —
|
|
10981
|
+
* and from QA, when the board has it — so nothing reaches Done but through
|
|
10982
|
+
* review. Done's WIP limit and every other column stay as they are. A board
|
|
10983
|
+
* without Done, or with neither In Review nor QA, has nothing to apply it to.
|
|
10984
|
+
*/
|
|
10985
|
+
async function applyStandardFlow(store, workspaceId, actor, scope) {
|
|
10986
|
+
assertPersonSetsEntryRules(actor, { workspaceId });
|
|
10987
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
10988
|
+
const columns = await listColumns(tx, workspaceId);
|
|
10989
|
+
if (!await writeStandardFlow(tx, columns)) throw new BoardError("issue_status_not_found", {
|
|
10990
|
+
workspaceId,
|
|
10991
|
+
statusName: columns.some((column) => normalizeStatusName(column.name) === STANDARD_FLOW_TARGET) ? "In Review" : "Done"
|
|
10992
|
+
});
|
|
10993
|
+
return await listColumns(tx, workspaceId);
|
|
10994
|
+
}, scope);
|
|
10995
|
+
}
|
|
10996
|
+
/** Done takes cards from the board's In Review and QA; `false`, with nothing written, when the board has no such columns. */
|
|
10997
|
+
async function writeStandardFlow(store, columns) {
|
|
10998
|
+
const flow = standardFlowOf(columns);
|
|
10999
|
+
if (!flow) return false;
|
|
11000
|
+
await store.statuses.update(flow.target.id, { allowedFrom: serializeAllowedFrom(flow.sources.map((column) => column.id)) });
|
|
11001
|
+
return true;
|
|
11002
|
+
}
|
|
11003
|
+
/**
|
|
11004
|
+
* Take a deleted column out of every list of where cards may come from. A list
|
|
11005
|
+
* it empties stays empty: a column that took cards only from it does not open
|
|
11006
|
+
* to every column.
|
|
11007
|
+
*/
|
|
11008
|
+
async function dropFromAllowedFrom(store, workspaceId, statusId) {
|
|
11009
|
+
for (const other of await listColumns(store, workspaceId)) {
|
|
11010
|
+
const { allowedFrom } = readFlowRules(other);
|
|
11011
|
+
if (allowedFrom?.includes(statusId)) await store.statuses.update(other.id, { allowedFrom: serializeAllowedFrom(allowedFrom.filter((id) => id !== statusId)) });
|
|
11012
|
+
}
|
|
11013
|
+
}
|
|
10833
11014
|
/**
|
|
10834
11015
|
* Take an empty column off the board, and number the columns left 0..n-1 in
|
|
10835
11016
|
* the same write.
|
|
@@ -10856,6 +11037,7 @@ async function deleteColumn(store, statusId, scope, actor) {
|
|
|
10856
11037
|
hint: "Move its cards to another column first, or remove it with removeColumn and moveCardsTo."
|
|
10857
11038
|
});
|
|
10858
11039
|
await tx.statuses.delete(statusId);
|
|
11040
|
+
await dropFromAllowedFrom(tx, column.workspaceId, statusId);
|
|
10859
11041
|
await renumberColumns(tx, column.workspaceId);
|
|
10860
11042
|
}, scope);
|
|
10861
11043
|
}
|
|
@@ -11135,24 +11317,46 @@ function pullRequestFactKeys({ owner, repo, number }) {
|
|
|
11135
11317
|
//#endregion
|
|
11136
11318
|
//#region src/ops/entry-rules.ts
|
|
11137
11319
|
/**
|
|
11138
|
-
* The column's entry rules the card does not meet
|
|
11139
|
-
*
|
|
11140
|
-
*
|
|
11320
|
+
* The column's entry rules and flow rules the card does not meet, entry rules
|
|
11321
|
+
* first (ruling 6-2; board-workflow spec, Part 2). A column with no rules
|
|
11322
|
+
* answers at once, without reading anything; otherwise only the facts its
|
|
11323
|
+
* rules need are read. `entering` counts the cards this same write already let
|
|
11324
|
+
* into the column, so a bulk move is held to the WIP limit as a whole.
|
|
11141
11325
|
*/
|
|
11142
|
-
async function checkColumnEntry(store, card, column) {
|
|
11326
|
+
async function checkColumnEntry(store, card, column, entering) {
|
|
11143
11327
|
const rules = readEntryRules(column);
|
|
11144
|
-
|
|
11145
|
-
|
|
11328
|
+
const unmet = rules.length === 0 ? [] : evaluateEntryRules(card, rules, await readEntryRuleFacts(store, card.id, rules)).unmet;
|
|
11329
|
+
const flow = readFlowRules(column);
|
|
11330
|
+
if (flow.allowedFrom === null && flow.wipLimit === null) return unmet;
|
|
11331
|
+
return [...unmet, ...evaluateFlowRules(card, column, flow, await readFlowRuleFacts(store, column, flow, entering))];
|
|
11332
|
+
}
|
|
11333
|
+
/** What the flow rules need: the board's column names when the column names where cards come from, and its card count when it has a limit. */
|
|
11334
|
+
async function readFlowRuleFacts(store, column, flow, entering) {
|
|
11335
|
+
const columnNames = flow.allowedFrom === null ? /* @__PURE__ */ new Map() : new Map((await store.statuses.listByWorkspace(column.workspaceId)).map((other) => [other.id, other.name]));
|
|
11336
|
+
if (flow.wipLimit === null) return {
|
|
11337
|
+
columnNames,
|
|
11338
|
+
cardsInColumn: 0
|
|
11339
|
+
};
|
|
11340
|
+
await store.statuses.lockById(column.id);
|
|
11341
|
+
return {
|
|
11342
|
+
columnNames,
|
|
11343
|
+
cardsInColumn: (await store.issues.listPage({
|
|
11344
|
+
workspaceId: column.workspaceId,
|
|
11345
|
+
statusIds: [column.id],
|
|
11346
|
+
limit: 1
|
|
11347
|
+
})).total + entering
|
|
11348
|
+
};
|
|
11146
11349
|
}
|
|
11147
11350
|
/**
|
|
11148
|
-
* Hold a card at the door of a column that asks for more than it has
|
|
11149
|
-
* 6-3)
|
|
11150
|
-
*
|
|
11151
|
-
*
|
|
11152
|
-
*
|
|
11351
|
+
* Hold a card at the door of a column that asks for more than it has — entry
|
|
11352
|
+
* rules (ruling 6-3), or flow rules: a column it may not come from, or a column
|
|
11353
|
+
* that is full. Anyone but a person — or a person's own terminal,
|
|
11354
|
+
* `placesForPerson` (ruling 6-6) — is refused with `board_column_rules_unmet`,
|
|
11355
|
+
* which names the column and everything unmet; a person is let through, and
|
|
11356
|
+
* the unmet list comes back so the caller can warn them.
|
|
11153
11357
|
*/
|
|
11154
|
-
async function enforceColumnEntry(store, card, column, actor) {
|
|
11155
|
-
const unmet = await checkColumnEntry(store, card, column);
|
|
11358
|
+
async function enforceColumnEntry(store, card, column, actor, entering = 0) {
|
|
11359
|
+
const unmet = await checkColumnEntry(store, card, column, entering);
|
|
11156
11360
|
if (unmet.length > 0 && actor.kind !== "user" && !actor.placesForPerson) throw new BoardError("board_column_rules_unmet", {
|
|
11157
11361
|
issueId: card.id,
|
|
11158
11362
|
column: column.name,
|
|
@@ -11165,14 +11369,17 @@ async function enforceColumnEntry(store, card, column, actor) {
|
|
|
11165
11369
|
* The same door for several cards entering one column at once — a bulk status
|
|
11166
11370
|
* change. All or nothing: the first card an agent may not move refuses the
|
|
11167
11371
|
* whole change. A person gets back the cards that entered with rules unmet.
|
|
11372
|
+
* Each card counts against the column's WIP limit as the cards before it in the same change entered it.
|
|
11168
11373
|
*/
|
|
11169
11374
|
async function enforceColumnEntryForCards(store, issueIds, statusId, actor) {
|
|
11170
11375
|
const column = await store.statuses.findById(statusId);
|
|
11171
11376
|
if (!column) throw new BoardError("issue_status_not_found", { statusId });
|
|
11172
11377
|
const entered = [];
|
|
11378
|
+
let entering = 0;
|
|
11173
11379
|
for (const card of await store.issues.listByIds(issueIds)) {
|
|
11174
11380
|
if (card.statusId === column.id) continue;
|
|
11175
|
-
const unmet = await enforceColumnEntry(store, card, column, actor);
|
|
11381
|
+
const unmet = await enforceColumnEntry(store, card, column, actor, entering);
|
|
11382
|
+
entering++;
|
|
11176
11383
|
if (unmet.length > 0) entered.push({
|
|
11177
11384
|
issueId: card.id,
|
|
11178
11385
|
unmet
|
|
@@ -11327,11 +11534,24 @@ const STOPPED_BY_YOU_STATUS_LINE = "stopped by you";
|
|
|
11327
11534
|
* the board reads the same whether the agent was launched by an app or from
|
|
11328
11535
|
* somewhere else entirely. The attempt number is simply how many runs the card
|
|
11329
11536
|
* has had — a card whose runs were all launched elsewhere still counts them.
|
|
11537
|
+
*
|
|
11538
|
+
* A card has one run going at a time. A start on a card whose run is still going
|
|
11539
|
+
* is refused with `board_card_run_active`, naming that run — whichever path it
|
|
11540
|
+
* came by, an app's launch, `kanbo run start`, MCP, or a second app on a shared
|
|
11541
|
+
* Postgres board. The card's row is locked first, so two starts racing for it
|
|
11542
|
+
* are settled one after the other.
|
|
11330
11543
|
*/
|
|
11331
11544
|
async function startRun(store, issueId, input, actor, scope) {
|
|
11332
11545
|
const externalSessionRef = readExternalSessionRef(input.externalSessionRef) ?? null;
|
|
11333
11546
|
return await runBoardWrite(store, async ({ tx }) => {
|
|
11334
|
-
const card = await
|
|
11547
|
+
const card = await tx.issues.lockById(issueId);
|
|
11548
|
+
if (!card) throw new BoardError("issue_not_found", { issueId });
|
|
11549
|
+
const [active] = await tx.runs.listRunning(issueId);
|
|
11550
|
+
if (active) throw new BoardError("board_card_run_active", {
|
|
11551
|
+
issueId,
|
|
11552
|
+
runId: active.id,
|
|
11553
|
+
agentName: active.agentName
|
|
11554
|
+
});
|
|
11335
11555
|
const attempt = await tx.runs.countByIssue(issueId) + 1;
|
|
11336
11556
|
const executionMode = input.executionMode ?? card.executionMode;
|
|
11337
11557
|
const run = await tx.runs.create({
|
|
@@ -11597,7 +11817,8 @@ async function createCard(store, input, actor, scope) {
|
|
|
11597
11817
|
const column = statusId === null ? null : await tx.statuses.findById(statusId);
|
|
11598
11818
|
const unmet = column ? await enforceColumnEntry(tx, {
|
|
11599
11819
|
id: null,
|
|
11600
|
-
description: input.description ?? null
|
|
11820
|
+
description: input.description ?? null,
|
|
11821
|
+
statusId: null
|
|
11601
11822
|
}, column, actor) : [];
|
|
11602
11823
|
const milestoneId = await requireMilestoneInWorkspace(tx, workspaceId, input.milestoneId ?? null);
|
|
11603
11824
|
const parentIssueId = await requireParentInWorkspace(tx, workspaceId, input.parentIssueId ?? null);
|
|
@@ -11605,8 +11826,9 @@ async function createCard(store, input, actor, scope) {
|
|
|
11605
11826
|
const now = currentUnixSeconds();
|
|
11606
11827
|
for (let attempt = 1;; attempt++) {
|
|
11607
11828
|
const identity = await nextIssueIdentity(input.workspace, tx);
|
|
11829
|
+
let created;
|
|
11608
11830
|
try {
|
|
11609
|
-
|
|
11831
|
+
created = await tx.issues.create({
|
|
11610
11832
|
id: identity.id,
|
|
11611
11833
|
workspaceId,
|
|
11612
11834
|
title: input.title?.trim() || identity.id,
|
|
@@ -11630,12 +11852,25 @@ async function createCard(store, input, actor, scope) {
|
|
|
11630
11852
|
order,
|
|
11631
11853
|
createdAt: now,
|
|
11632
11854
|
updatedAt: now
|
|
11633
|
-
})
|
|
11855
|
+
});
|
|
11634
11856
|
} catch (error) {
|
|
11635
11857
|
const numberTaken = await tx.issues.findByNumber(workspaceId, identity.number);
|
|
11636
11858
|
const keyTaken = await tx.issues.findById(identity.id);
|
|
11637
11859
|
if (attempt >= NUMBERING_ATTEMPTS || !numberTaken && !keyTaken) throw error;
|
|
11860
|
+
continue;
|
|
11638
11861
|
}
|
|
11862
|
+
if (statusId !== null) await tx.fieldChanges.create({
|
|
11863
|
+
id: (0, node_crypto.randomUUID)(),
|
|
11864
|
+
issueId: created.id,
|
|
11865
|
+
field: "statusId",
|
|
11866
|
+
fromValue: null,
|
|
11867
|
+
toValue: statusId,
|
|
11868
|
+
actorKind: toStoredActorKind(actor),
|
|
11869
|
+
actorId: actor.id ?? null,
|
|
11870
|
+
sourceChatSessionId: actor.chatSessionId ?? null,
|
|
11871
|
+
createdAt: now
|
|
11872
|
+
});
|
|
11873
|
+
return withUnmetRules(created, unmet);
|
|
11639
11874
|
}
|
|
11640
11875
|
}, scope);
|
|
11641
11876
|
}
|
|
@@ -11691,7 +11926,8 @@ async function updateCard(store, issueId, input, actor, scope) {
|
|
|
11691
11926
|
const description = "description" in updates ? updates.description ?? null : card.description;
|
|
11692
11927
|
unmet = await enforceColumnEntry(tx, {
|
|
11693
11928
|
id: card.id,
|
|
11694
|
-
description
|
|
11929
|
+
description,
|
|
11930
|
+
statusId: card.statusId
|
|
11695
11931
|
}, column, actor);
|
|
11696
11932
|
}
|
|
11697
11933
|
if ("assigneeKind" in input) updates.assigneeKind = input.assigneeKind ?? null;
|
|
@@ -11994,12 +12230,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
|
|
|
11994
12230
|
issueId,
|
|
11995
12231
|
parentIssueId
|
|
11996
12232
|
});
|
|
11997
|
-
|
|
12233
|
+
const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
|
|
12234
|
+
if (!parent) throw new BoardError("issue_parent_not_found", {
|
|
11998
12235
|
workspaceId,
|
|
11999
12236
|
parentIssueId
|
|
12000
12237
|
});
|
|
12238
|
+
if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new BoardError("issue_parent_cycle", {
|
|
12239
|
+
issueId,
|
|
12240
|
+
parentIssueId
|
|
12241
|
+
});
|
|
12001
12242
|
return parentIssueId;
|
|
12002
12243
|
}
|
|
12244
|
+
/**
|
|
12245
|
+
* Is the card somewhere under `ancestorId` — its parent, its parent's parent,
|
|
12246
|
+
* and so on up? A card put under one of its own sub-cards would make a loop no
|
|
12247
|
+
* reader of the board can walk to the top of. The cards already seen end the
|
|
12248
|
+
* walk, so a loop the data holds from before cannot hang it.
|
|
12249
|
+
*/
|
|
12250
|
+
async function isUnder(store, card, ancestorId) {
|
|
12251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12252
|
+
let id = card.parentIssueId;
|
|
12253
|
+
while (id !== null && !seen.has(id)) {
|
|
12254
|
+
if (id === ancestorId) return true;
|
|
12255
|
+
seen.add(id);
|
|
12256
|
+
id = (await store.issues.findById(id))?.parentIssueId ?? null;
|
|
12257
|
+
}
|
|
12258
|
+
return false;
|
|
12259
|
+
}
|
|
12003
12260
|
/** Is the card's own number already taken by another card in the target workspace? */
|
|
12004
12261
|
async function numberTakenByAnother(store, workspaceId, card) {
|
|
12005
12262
|
const holder = await store.issues.findByNumber(workspaceId, card.number);
|
|
@@ -12069,6 +12326,160 @@ async function reorderCards(store, workspaceId, orderedIds, scope) {
|
|
|
12069
12326
|
}, scope);
|
|
12070
12327
|
}
|
|
12071
12328
|
//#endregion
|
|
12329
|
+
//#region src/ops/ready.ts
|
|
12330
|
+
/** The column an agent takes its next card from. */
|
|
12331
|
+
const READY_COLUMN_SLUG = "to_do";
|
|
12332
|
+
/**
|
|
12333
|
+
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
12334
|
+
* nobody's turn but the agent's.
|
|
12335
|
+
*
|
|
12336
|
+
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
12337
|
+
* run is taken, and a card waiting for a person belongs to that person until
|
|
12338
|
+
* they answer. What is left is the queue — in board order, so the card someone
|
|
12339
|
+
* dragged to the top is the one an agent asking for work gets.
|
|
12340
|
+
*/
|
|
12341
|
+
async function listReady(store, input) {
|
|
12342
|
+
return (await queryReady(store, input)).cards;
|
|
12343
|
+
}
|
|
12344
|
+
/**
|
|
12345
|
+
* One page of the ready cards, how many are ready in all, and the columns read
|
|
12346
|
+
* on the way — the rule above, asked of the database in one statement rather
|
|
12347
|
+
* than of every card on the board.
|
|
12348
|
+
*/
|
|
12349
|
+
async function queryReady(store, input) {
|
|
12350
|
+
const columns = await listColumns(store, input.workspaceId);
|
|
12351
|
+
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
12352
|
+
if (!column) return {
|
|
12353
|
+
cards: [],
|
|
12354
|
+
total: 0,
|
|
12355
|
+
columns
|
|
12356
|
+
};
|
|
12357
|
+
return {
|
|
12358
|
+
...await store.issues.listPage({
|
|
12359
|
+
workspaceId: input.workspaceId,
|
|
12360
|
+
statusIds: [column.id],
|
|
12361
|
+
waitingForPerson: false,
|
|
12362
|
+
hasActiveRun: false,
|
|
12363
|
+
limit: input.limit,
|
|
12364
|
+
offset: input.offset
|
|
12365
|
+
}),
|
|
12366
|
+
columns
|
|
12367
|
+
};
|
|
12368
|
+
}
|
|
12369
|
+
//#endregion
|
|
12370
|
+
//#region src/ops/claim.ts
|
|
12371
|
+
/**
|
|
12372
|
+
* Take a card to work on, in one board write: pick it, start its run, and move
|
|
12373
|
+
* it into the board's first `started` column (In Progress on a standard
|
|
12374
|
+
* board), its entry and flow rules checked on the way in. Anything refused
|
|
12375
|
+
* leaves nothing written.
|
|
12376
|
+
*
|
|
12377
|
+
* Two claimers never get the same card. On SQLite the write holds the file
|
|
12378
|
+
* from its first read (`begin immediate`), so the second claimer reads the
|
|
12379
|
+
* board after the first one's commit. On Postgres the picked card's row is
|
|
12380
|
+
* locked (`select … for update`): a claimer that waited on the lock finds the
|
|
12381
|
+
* card taken, and moves on to the next ready one. The target's row is locked
|
|
12382
|
+
* first, always, in the order a move into it locks (column, then card), so a
|
|
12383
|
+
* claim and a move never wait on each other in a circle.
|
|
12384
|
+
*/
|
|
12385
|
+
async function claimCard(store, input, actor, scope) {
|
|
12386
|
+
return await runBoardWrite(store, async ({ tx }) => {
|
|
12387
|
+
const columns = await listColumns(tx, input.workspaceId);
|
|
12388
|
+
const target = columns.find((column) => column.category === "started");
|
|
12389
|
+
if (!target) throw new BoardError("issue_status_not_found", {
|
|
12390
|
+
workspaceId: input.workspaceId,
|
|
12391
|
+
category: "started"
|
|
12392
|
+
});
|
|
12393
|
+
await tx.statuses.lockById(target.id);
|
|
12394
|
+
const toDo = columns.find((column) => normalizeStatusName(column.name) === "to_do") ?? null;
|
|
12395
|
+
const card = input.issueId === void 0 ? await takeFirstReady(tx, input.workspaceId, toDo) : await takeNamed(tx, input.workspaceId, input.issueId, toDo);
|
|
12396
|
+
const run = await startRun(tx, card.id, input.run, actor, { tx });
|
|
12397
|
+
return {
|
|
12398
|
+
card: await updateCard(tx, card.id, { statusId: target.id }, actor, { tx }),
|
|
12399
|
+
run,
|
|
12400
|
+
column: target
|
|
12401
|
+
};
|
|
12402
|
+
}, scope);
|
|
12403
|
+
}
|
|
12404
|
+
/**
|
|
12405
|
+
* The first ready card, locked. A card another claimer took while this one
|
|
12406
|
+
* waited for its lock is passed over, and the next read no longer lists it; the
|
|
12407
|
+
* cards passed over are counted so the loop ends with the list.
|
|
12408
|
+
*/
|
|
12409
|
+
async function takeFirstReady(store, workspaceId, toDo) {
|
|
12410
|
+
const passed = /* @__PURE__ */ new Set();
|
|
12411
|
+
for (;;) {
|
|
12412
|
+
const { cards } = await queryReady(store, {
|
|
12413
|
+
workspaceId,
|
|
12414
|
+
limit: passed.size + 1
|
|
12415
|
+
});
|
|
12416
|
+
const candidate = cards.find((card) => !passed.has(card.id));
|
|
12417
|
+
if (!candidate) throw new BoardError("board_nothing_ready", { workspaceId });
|
|
12418
|
+
const locked = await store.issues.lockById(candidate.id);
|
|
12419
|
+
if (locked && await notReadyReason(store, locked, toDo) === null) return locked;
|
|
12420
|
+
passed.add(candidate.id);
|
|
12421
|
+
}
|
|
12422
|
+
}
|
|
12423
|
+
/** The named card, locked, when it is on this board and ready — or why not. */
|
|
12424
|
+
async function takeNamed(store, workspaceId, issueId, toDo) {
|
|
12425
|
+
const card = await store.issues.lockById(issueId);
|
|
12426
|
+
if (!card || card.workspaceId !== workspaceId) throw new BoardError("issue_not_found", { issueId });
|
|
12427
|
+
const reason = await notReadyReason(store, card, toDo);
|
|
12428
|
+
if (reason) throw new BoardError("board_card_not_ready", {
|
|
12429
|
+
issueId,
|
|
12430
|
+
reason
|
|
12431
|
+
});
|
|
12432
|
+
return card;
|
|
12433
|
+
}
|
|
12434
|
+
/** `queryReady`'s rule for one card already read: nobody on it, nobody waited for, in To Do. */
|
|
12435
|
+
async function notReadyReason(store, card, toDo) {
|
|
12436
|
+
if ((await store.runs.listRunning(card.id)).length > 0) return "run_active";
|
|
12437
|
+
if (card.waitingFor === "human") return "waiting_for_person";
|
|
12438
|
+
return toDo !== null && card.statusId === toDo.id ? null : "not_in_to_do";
|
|
12439
|
+
}
|
|
12440
|
+
//#endregion
|
|
12441
|
+
//#region src/ops/column-entries.ts
|
|
12442
|
+
/**
|
|
12443
|
+
* Every entry into a column on the named boards at or after `since`, in order
|
|
12444
|
+
* of `(at, changeId)` — the one signal an app dispatching work on column
|
|
12445
|
+
* changes needs. It sees every writer, an outside `kanbo` on the same database
|
|
12446
|
+
* included, because it reads the cards' own history.
|
|
12447
|
+
*
|
|
12448
|
+
* A page never ends inside a second: when `limit` cuts into one, the rest of
|
|
12449
|
+
* that second comes too, so a page may be longer than `limit`. A reader keeps
|
|
12450
|
+
* its cursor at the last `at` it read after a short page — a write in that
|
|
12451
|
+
* second may still commit — and drops the `changeId`s it has seen; after a page
|
|
12452
|
+
* of `limit` or more it moves on to the last `at` + 1, which a second holding
|
|
12453
|
+
* more entries than `limit` would otherwise never let it pass.
|
|
12454
|
+
*/
|
|
12455
|
+
async function listColumnEntries(store, input) {
|
|
12456
|
+
const page = await store.fieldChanges.listByFieldSince("statusId", input);
|
|
12457
|
+
return (input.limit !== void 0 && input.limit > 0 && page.length === input.limit ? await withWholeLastSecond(store, input, page) : page).flatMap(toColumnEntry);
|
|
12458
|
+
}
|
|
12459
|
+
/** The page with every change of its last second, not only those `limit` let in. */
|
|
12460
|
+
async function withWholeLastSecond(store, input, page) {
|
|
12461
|
+
const last = page.at(-1).createdAt;
|
|
12462
|
+
const lastSecond = await store.fieldChanges.listByFieldSince("statusId", {
|
|
12463
|
+
workspaceIds: input.workspaceIds,
|
|
12464
|
+
since: last,
|
|
12465
|
+
until: last
|
|
12466
|
+
});
|
|
12467
|
+
return [...page.filter((change) => change.createdAt < last), ...lastSecond];
|
|
12468
|
+
}
|
|
12469
|
+
/** A status change as an entry; a change that took the card out of every column enters none. */
|
|
12470
|
+
function toColumnEntry(change) {
|
|
12471
|
+
return change.toValue === null ? [] : [{
|
|
12472
|
+
changeId: change.id,
|
|
12473
|
+
issueId: change.issueId,
|
|
12474
|
+
workspaceId: change.workspaceId,
|
|
12475
|
+
fromStatusId: change.fromValue,
|
|
12476
|
+
toStatusId: change.toValue,
|
|
12477
|
+
actorKind: change.actorKind,
|
|
12478
|
+
actorId: change.actorId,
|
|
12479
|
+
at: change.createdAt
|
|
12480
|
+
}];
|
|
12481
|
+
}
|
|
12482
|
+
//#endregion
|
|
12072
12483
|
//#region src/ops/context-refs.ts
|
|
12073
12484
|
const ContextRefsJsonSchema = string().transform((raw) => JSON.parse(raw)).pipe(array(string()));
|
|
12074
12485
|
/** Add a context ref at the end of the card's list, recording the change in its history. */
|
|
@@ -12253,6 +12664,7 @@ function subtaskSplitSentence(parentReference) {
|
|
|
12253
12664
|
}
|
|
12254
12665
|
const SUBTASK_RULE = `${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("the parent card's id")}`;
|
|
12255
12666
|
const PULL_REQUEST_RULE = "Opened a pull request? Link it to the card. Its state and CI checks then reach the card as comments on their own — nothing else tells the board a pull request exists.";
|
|
12667
|
+
const CLAIM_RULE = "Taking a card from To Do yourself? Claim it instead of starting a run and moving it: the claim does both in one step, and no other agent gets the same card. Finish the run when you stop.";
|
|
12256
12668
|
const OWN_SESSION_RULE = "Started the work yourself, not launched by an app that tracks the run for you? Record the run when you start and finish it when you stop. kanbo fills in the Claude Code or Codex session you are in; never invent a session id.";
|
|
12257
12669
|
/** Said by the `kanbo init` block and the MCP instructions, which name the comment command or tool beside it. */
|
|
12258
12670
|
const COMMENT_RULE = "Findings, results, decisions and questions go on the card as comments.";
|
|
@@ -12276,6 +12688,7 @@ const BOARD_RULES = [
|
|
|
12276
12688
|
EXECUTION_MODE_RULE,
|
|
12277
12689
|
SUBTASK_RULE,
|
|
12278
12690
|
PULL_REQUEST_RULE,
|
|
12691
|
+
CLAIM_RULE,
|
|
12279
12692
|
OWN_SESSION_RULE,
|
|
12280
12693
|
"Read the column descriptions above before moving a card, and do all of this through the board tools or commands you have — a statement in your reply changes nothing."
|
|
12281
12694
|
];
|
|
@@ -12291,12 +12704,14 @@ const BOARD_RULES = [
|
|
|
12291
12704
|
async function buildPrimeText(store, workspaceId) {
|
|
12292
12705
|
const columns = await listColumns(store, workspaceId);
|
|
12293
12706
|
const ruled = columns.some((column) => readEntryRules(column).length > 0);
|
|
12707
|
+
const flowing = columns.some((column) => hasFlowRules(column));
|
|
12294
12708
|
return [
|
|
12295
12709
|
...await describeReturnedCards(store, workspaceId, columns),
|
|
12296
12710
|
"Columns of this board, in board order:",
|
|
12297
12711
|
"",
|
|
12298
|
-
...columns.length > 0 ? columns.map(describeColumnLine) : ["- (this board has no columns yet)"],
|
|
12712
|
+
...columns.length > 0 ? columns.map((column) => describeColumnLine(column, columns)) : ["- (this board has no columns yet)"],
|
|
12299
12713
|
...ruled ? ["", ENTRY_RULES_NOTE] : [],
|
|
12714
|
+
...flowing ? ["", FLOW_RULES_NOTE] : [],
|
|
12300
12715
|
"",
|
|
12301
12716
|
"How this board works:",
|
|
12302
12717
|
"",
|
|
@@ -12338,11 +12753,22 @@ async function describeReturnedCards(store, workspaceId, columns) {
|
|
|
12338
12753
|
* without any reads exactly as it did before they existed.
|
|
12339
12754
|
*/
|
|
12340
12755
|
const ENTRY_RULES_NOTE = "A column that lists requirements refuses a card that does not meet them yet, with the list of what is missing: do that work first, then move the card. A person may still move it.";
|
|
12341
|
-
|
|
12756
|
+
/** Said under the columns only when one of them has flow rules. */
|
|
12757
|
+
const FLOW_RULES_NOTE = "A column that says which columns it accepts cards from, or how many cards it holds, refuses a move that breaks that and says why: move the card through a column it accepts, or wait until a card leaves. A person may still move it.";
|
|
12758
|
+
function hasFlowRules(column) {
|
|
12759
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
12760
|
+
return allowedFrom !== null || wipLimit !== null;
|
|
12761
|
+
}
|
|
12762
|
+
function describeColumnLine(column, columns) {
|
|
12342
12763
|
const slug = normalizeStatusName(column.name);
|
|
12343
12764
|
const rules = readEntryRules(column);
|
|
12344
|
-
const
|
|
12345
|
-
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${
|
|
12765
|
+
const said = `${rules.length > 0 ? ` Requires: ${rules.map((rule) => `${rule} (${ENTRY_RULE_DESCRIPTIONS[rule]})`).join("; ")}.` : ""}${describeFlow(column, columns)}`;
|
|
12766
|
+
return column.description ? `- ${column.name} (\`${slug}\`) — ${column.description}${said}` : `- ${column.name} (\`${slug}\`)${said ? ` —${said}` : ""}`;
|
|
12767
|
+
}
|
|
12768
|
+
/** Where a column takes cards from and how many it holds, as the prime text says it; nothing for a column without flow rules. */
|
|
12769
|
+
function describeFlow(column, columns) {
|
|
12770
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
12771
|
+
return `${allowedFrom === null ? "" : allowedFrom.length === 0 ? " Accepts no card from another column: only a person puts a card here." : ` Accepts cards from ${joinWords(columns.filter((other) => allowedFrom.includes(other.id)).map((other) => `\`${normalizeStatusName(other.name)}\``))} only.`}${wipLimit === null ? "" : ` Holds at most ${wipLimit} ${wipLimit === 1 ? "card" : "cards"}.`}`;
|
|
12346
12772
|
}
|
|
12347
12773
|
//#endregion
|
|
12348
12774
|
//#region src/domain/pull-request-ref.ts
|
|
@@ -12512,47 +12938,6 @@ async function queryCards(store, input) {
|
|
|
12512
12938
|
};
|
|
12513
12939
|
}
|
|
12514
12940
|
//#endregion
|
|
12515
|
-
//#region src/ops/ready.ts
|
|
12516
|
-
/** The column an agent takes its next card from. */
|
|
12517
|
-
const READY_COLUMN_SLUG = "to_do";
|
|
12518
|
-
/**
|
|
12519
|
-
* The cards an agent may pick up right now: spelled out, nobody working on them,
|
|
12520
|
-
* nobody's turn but the agent's.
|
|
12521
|
-
*
|
|
12522
|
-
* "Ready" is deliberately narrow. A card in Backlog is an idea, a card with a
|
|
12523
|
-
* run is taken, and a card waiting for a person belongs to that person until
|
|
12524
|
-
* they answer. What is left is the queue — in board order, so the card someone
|
|
12525
|
-
* dragged to the top is the one an agent asking for work gets.
|
|
12526
|
-
*/
|
|
12527
|
-
async function listReady(store, input) {
|
|
12528
|
-
return (await queryReady(store, input)).cards;
|
|
12529
|
-
}
|
|
12530
|
-
/**
|
|
12531
|
-
* One page of the ready cards, how many are ready in all, and the columns read
|
|
12532
|
-
* on the way — the rule above, asked of the database in one statement rather
|
|
12533
|
-
* than of every card on the board.
|
|
12534
|
-
*/
|
|
12535
|
-
async function queryReady(store, input) {
|
|
12536
|
-
const columns = await listColumns(store, input.workspaceId);
|
|
12537
|
-
const column = columns.find((candidate) => normalizeStatusName(candidate.name) === READY_COLUMN_SLUG);
|
|
12538
|
-
if (!column) return {
|
|
12539
|
-
cards: [],
|
|
12540
|
-
total: 0,
|
|
12541
|
-
columns
|
|
12542
|
-
};
|
|
12543
|
-
return {
|
|
12544
|
-
...await store.issues.listPage({
|
|
12545
|
-
workspaceId: input.workspaceId,
|
|
12546
|
-
statusIds: [column.id],
|
|
12547
|
-
waitingForPerson: false,
|
|
12548
|
-
hasActiveRun: false,
|
|
12549
|
-
limit: input.limit,
|
|
12550
|
-
offset: input.offset
|
|
12551
|
-
}),
|
|
12552
|
-
columns
|
|
12553
|
-
};
|
|
12554
|
-
}
|
|
12555
|
-
//#endregion
|
|
12556
12941
|
//#region src/ops/relations.ts
|
|
12557
12942
|
/**
|
|
12558
12943
|
* Every edge touching the card, in either direction, each with the card on its
|
|
@@ -12842,6 +13227,8 @@ function createBoardOps(store) {
|
|
|
12842
13227
|
updateColumn: async (statusId, patch, actor, scope) => await updateColumn(store, statusId, patch, actor, scope),
|
|
12843
13228
|
describeColumn: async (workspaceId, nameOrId, description, scope) => await describeColumn(store, workspaceId, nameOrId, description, scope),
|
|
12844
13229
|
setColumnEntryRules: async (workspaceId, nameOrId, entryRules, actor, scope) => await setColumnEntryRules(store, workspaceId, nameOrId, entryRules, actor, scope),
|
|
13230
|
+
setColumnFlowRules: async (columnId, rules, actor, scope) => await setColumnFlowRules(store, columnId, rules, actor, scope),
|
|
13231
|
+
applyStandardFlow: async (workspaceId, actor, scope) => await applyStandardFlow(store, workspaceId, actor, scope),
|
|
12845
13232
|
enforceColumnEntryForCards: async (issueIds, statusId, actor) => await enforceColumnEntryForCards(store, issueIds, statusId, actor),
|
|
12846
13233
|
deleteColumn: async (statusId, scope, actor) => await deleteColumn(store, statusId, scope, actor),
|
|
12847
13234
|
reorderColumns: async (workspaceId, orderedIds, scope, actor) => await reorderColumns(store, workspaceId, orderedIds, scope, actor),
|
|
@@ -12852,6 +13239,7 @@ function createBoardOps(store) {
|
|
|
12852
13239
|
moveColumn: async (workspaceId, nameOrId, position, actor, scope) => await moveColumn$1(store, workspaceId, nameOrId, position, actor, scope),
|
|
12853
13240
|
removeColumn: async (workspaceId, nameOrId, options, actor, scope) => await removeColumn$1(store, workspaceId, nameOrId, options, actor, scope),
|
|
12854
13241
|
startRun: async (issueId, input, actor, scope) => await startRun(store, issueId, input, actor, scope),
|
|
13242
|
+
claimCard: async (input, actor, scope) => await claimCard(store, input, actor, scope),
|
|
12855
13243
|
attachRunExecution: async (runId, input, scope) => await attachRunExecution(store, runId, input, scope),
|
|
12856
13244
|
recordRunSessionRef: async (runId, ref, scope) => await recordRunSessionRef(store, runId, ref, scope),
|
|
12857
13245
|
clearRunSessionRef: async (runId, actor, options, scope) => await clearRunSessionRef(store, runId, actor, options, scope),
|
|
@@ -12864,6 +13252,7 @@ function createBoardOps(store) {
|
|
|
12864
13252
|
readBoardProjectionForIssue: async (issueId) => await readBoardProjectionForIssue(store, issueId),
|
|
12865
13253
|
listReady: async (input) => await listReady(store, input),
|
|
12866
13254
|
queryReady: async (input) => await queryReady(store, input),
|
|
13255
|
+
listColumnEntries: async (input) => await listColumnEntries(store, input),
|
|
12867
13256
|
buildPrimeText: async (workspaceId) => await buildPrimeText(store, workspaceId)
|
|
12868
13257
|
};
|
|
12869
13258
|
}
|
|
@@ -18730,6 +19119,10 @@ const issueStatuses$1 = pgTable("issue_statuses", {
|
|
|
18730
19119
|
order: integer$1("order").notNull().default(0),
|
|
18731
19120
|
/** A JSON array of `ENTRY_RULES`, or `null` for none — see the SQLite schema. */
|
|
18732
19121
|
entryRules: text$1("entry_rules"),
|
|
19122
|
+
/** A JSON array of the status ids a card may enter from, or `null` for any — see the SQLite schema. */
|
|
19123
|
+
allowedFrom: text$1("allowed_from"),
|
|
19124
|
+
/** The most cards the column holds, or `null` — see the SQLite schema. */
|
|
19125
|
+
wipLimit: integer$1("wip_limit"),
|
|
18733
19126
|
...createdAt$1()
|
|
18734
19127
|
}, (table) => [index$1("issue_statuses_workspace_id_idx").on(table.workspaceId), uniqueIndex$1("issue_statuses_workspace_name_unique").on(table.workspaceId, table.name)]);
|
|
18735
19128
|
const issueMilestones$1 = pgTable("issue_milestones", {
|
|
@@ -18877,7 +19270,7 @@ const issueFieldChanges$1 = pgTable("issue_field_changes", {
|
|
|
18877
19270
|
...createdAt$1(),
|
|
18878
19271
|
/** Insertion order, standing in for the `rowid` the SQLite board orders changes by. */
|
|
18879
19272
|
seq: bigserial("seq", { mode: "number" }).notNull()
|
|
18880
|
-
}, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId)]);
|
|
19273
|
+
}, (table) => [index$1("issue_field_changes_issue_id_idx").on(table.issueId), index$1("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)]);
|
|
18881
19274
|
/** A pull request someone said belongs to a card — see the SQLite twin for what the row is and is not. */
|
|
18882
19275
|
const issuePullRequests$1 = pgTable("issue_pull_requests", {
|
|
18883
19276
|
id: textPk$1(),
|
|
@@ -18953,6 +19346,10 @@ function toIssueComment({ seq: _seq, ...comment }) {
|
|
|
18953
19346
|
function toIssueFieldChange({ seq: _seq, ...change }) {
|
|
18954
19347
|
return change;
|
|
18955
19348
|
}
|
|
19349
|
+
/** A field change with its card's workspace, without `seq`, for the same reason. */
|
|
19350
|
+
function toWorkspaceFieldChange({ seq: _seq, ...change }) {
|
|
19351
|
+
return change;
|
|
19352
|
+
}
|
|
18956
19353
|
/** The pull-request link without `seq`, for the same reason. */
|
|
18957
19354
|
function toIssuePullRequest({ seq: _seq, ...link }) {
|
|
18958
19355
|
return link;
|
|
@@ -19159,6 +19556,10 @@ function createStoreOver(database) {
|
|
|
19159
19556
|
const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1);
|
|
19160
19557
|
return row ?? null;
|
|
19161
19558
|
},
|
|
19559
|
+
lockById: async (issueId) => {
|
|
19560
|
+
const [row] = await database.select().from(issues$1).where(eq(issues$1.id, issueId)).limit(1).for("update");
|
|
19561
|
+
return row ?? null;
|
|
19562
|
+
},
|
|
19162
19563
|
findInWorkspace: async (workspaceId, issueId) => {
|
|
19163
19564
|
const [row] = await database.select().from(issues$1).where(and(eq(issues$1.id, issueId), eq(issues$1.workspaceId, workspaceId))).limit(1);
|
|
19164
19565
|
return row ?? null;
|
|
@@ -19310,6 +19711,10 @@ function createStoreOver(database) {
|
|
|
19310
19711
|
fieldChanges: {
|
|
19311
19712
|
listByIssue: async (issueId) => (await database.select().from(issueFieldChanges$1).where(eq(issueFieldChanges$1.issueId, issueId)).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
|
|
19312
19713
|
listByIssueField: async (issueId, field) => (await database.select().from(issueFieldChanges$1).where(and(eq(issueFieldChanges$1.issueId, issueId), eq(issueFieldChanges$1.field, field))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.seq))).map(toIssueFieldChange),
|
|
19714
|
+
listByFieldSince: async (field, query) => query.workspaceIds.length === 0 ? [] : (await database.select({
|
|
19715
|
+
...getTableColumns(issueFieldChanges$1),
|
|
19716
|
+
workspaceId: issues$1.workspaceId
|
|
19717
|
+
}).from(issueFieldChanges$1).innerJoin(issues$1, eq(issues$1.id, issueFieldChanges$1.issueId)).where(and(eq(issueFieldChanges$1.field, field), isNotNull(issueFieldChanges$1.toValue), gte(issueFieldChanges$1.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges$1.createdAt, query.until), inArray(issues$1.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges$1.createdAt), asc(issueFieldChanges$1.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER)).map(toWorkspaceFieldChange),
|
|
19313
19718
|
create: async (values) => {
|
|
19314
19719
|
await database.insert(issueFieldChanges$1).values(values);
|
|
19315
19720
|
}
|
|
@@ -20127,6 +20532,14 @@ const issueStatuses = sqliteTable("issue_statuses", {
|
|
|
20127
20532
|
* through `readEntryRules`, never by hand.
|
|
20128
20533
|
*/
|
|
20129
20534
|
entryRules: text("entry_rules"),
|
|
20535
|
+
/**
|
|
20536
|
+
* The columns a card may enter this one from — a JSON array of status ids;
|
|
20537
|
+
* `null` for any column, `[]` for none (only a person puts a card here). Read
|
|
20538
|
+
* it through `readFlowRules`, never by hand.
|
|
20539
|
+
*/
|
|
20540
|
+
allowedFrom: text("allowed_from"),
|
|
20541
|
+
/** The most cards this column holds, 1 or more; `null` for no limit. */
|
|
20542
|
+
wipLimit: int("wip_limit"),
|
|
20130
20543
|
...createdAt()
|
|
20131
20544
|
}, (table) => ({
|
|
20132
20545
|
byWorkspace: index("issue_statuses_workspace_id_idx").on(table.workspaceId),
|
|
@@ -20274,7 +20687,11 @@ const issueFieldChanges = sqliteTable("issue_field_changes", {
|
|
|
20274
20687
|
actorId: text("actor_id"),
|
|
20275
20688
|
sourceChatSessionId: text("source_chat_session_id"),
|
|
20276
20689
|
...createdAt()
|
|
20277
|
-
}, (table) => ({
|
|
20690
|
+
}, (table) => ({
|
|
20691
|
+
byIssue: index("issue_field_changes_issue_id_idx").on(table.issueId),
|
|
20692
|
+
/** The column-entry feed reads one field across a board by time. */
|
|
20693
|
+
byFieldCreatedAt: index("issue_field_changes_field_created_at_idx").on(table.field, table.createdAt)
|
|
20694
|
+
}));
|
|
20278
20695
|
/**
|
|
20279
20696
|
* A pull request someone said belongs to a card.
|
|
20280
20697
|
*
|
|
@@ -20617,6 +21034,7 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20617
21034
|
}).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId).all(),
|
|
20618
21035
|
listByIds: (issueIds) => resolveDatabase().select().from(issues).where(inArray(issues.id, issueIds)).all(),
|
|
20619
21036
|
findById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
|
|
21037
|
+
lockById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
|
|
20620
21038
|
findInWorkspace: (workspaceId, issueId) => resolveDatabase().select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).get() ?? null,
|
|
20621
21039
|
findByNumber: (workspaceId, number) => resolveDatabase().select().from(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.number, number))).get() ?? null,
|
|
20622
21040
|
maxNumber: (workspaceId) => {
|
|
@@ -20714,6 +21132,10 @@ function createSqliteBoardStoreCore(resolveDatabase) {
|
|
|
20714
21132
|
fieldChanges: {
|
|
20715
21133
|
listByIssue: (issueId) => resolveDatabase().select().from(issueFieldChanges).where(eq(issueFieldChanges.issueId, issueId)).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
|
|
20716
21134
|
listByIssueField: (issueId, field) => resolveDatabase().select().from(issueFieldChanges).where(and(eq(issueFieldChanges.issueId, issueId), eq(issueFieldChanges.field, field))).orderBy(asc(issueFieldChanges.createdAt), FIELD_CHANGE_INSERTION_ORDER).all(),
|
|
21135
|
+
listByFieldSince: (field, query) => query.workspaceIds.length === 0 ? [] : resolveDatabase().select({
|
|
21136
|
+
...getTableColumns(issueFieldChanges),
|
|
21137
|
+
workspaceId: issues.workspaceId
|
|
21138
|
+
}).from(issueFieldChanges).innerJoin(issues, eq(issues.id, issueFieldChanges.issueId)).where(and(eq(issueFieldChanges.field, field), isNotNull(issueFieldChanges.toValue), gte(issueFieldChanges.createdAt, query.since), query.until === void 0 ? void 0 : lte(issueFieldChanges.createdAt, query.until), inArray(issues.workspaceId, [...query.workspaceIds]))).orderBy(asc(issueFieldChanges.createdAt), asc(issueFieldChanges.id)).limit(query.limit ?? Number.MAX_SAFE_INTEGER).all(),
|
|
20717
21139
|
create: (values) => {
|
|
20718
21140
|
resolveDatabase().insert(issueFieldChanges).values(values).run();
|
|
20719
21141
|
}
|
|
@@ -20794,6 +21216,7 @@ function createSqliteBoardStore(options) {
|
|
|
20794
21216
|
countByMilestone: async (workspaceId) => core.issues.countByMilestone(workspaceId),
|
|
20795
21217
|
listByIds: async (issueIds) => core.issues.listByIds(issueIds),
|
|
20796
21218
|
findById: async (issueId) => core.issues.findById(issueId),
|
|
21219
|
+
lockById: async (issueId) => core.issues.lockById(issueId),
|
|
20797
21220
|
findInWorkspace: async (workspaceId, issueId) => core.issues.findInWorkspace(workspaceId, issueId),
|
|
20798
21221
|
findByNumber: async (workspaceId, number) => core.issues.findByNumber(workspaceId, number),
|
|
20799
21222
|
maxNumber: async (workspaceId) => core.issues.maxNumber(workspaceId),
|
|
@@ -20839,6 +21262,7 @@ function createSqliteBoardStore(options) {
|
|
|
20839
21262
|
fieldChanges: {
|
|
20840
21263
|
listByIssue: async (issueId) => core.fieldChanges.listByIssue(issueId),
|
|
20841
21264
|
listByIssueField: async (issueId, field) => core.fieldChanges.listByIssueField(issueId, field),
|
|
21265
|
+
listByFieldSince: async (field, query) => core.fieldChanges.listByFieldSince(field, query),
|
|
20842
21266
|
create: async (values) => core.fieldChanges.create(values)
|
|
20843
21267
|
},
|
|
20844
21268
|
pullRequests: {
|
|
@@ -23776,6 +24200,111 @@ async function openWithPostgresJs(url) {
|
|
|
23776
24200
|
};
|
|
23777
24201
|
}
|
|
23778
24202
|
//#endregion
|
|
24203
|
+
//#region src/cli/project-registry.ts
|
|
24204
|
+
/**
|
|
24205
|
+
* Which project folders kanbo has opened a board in.
|
|
24206
|
+
*
|
|
24207
|
+
* Every project keeps its board in its own folder, so nothing else knows which
|
|
24208
|
+
* other projects exist. This list, in the user's home, is what lets one board
|
|
24209
|
+
* page offer them all. It holds a folder and a time — never a connection
|
|
24210
|
+
* string, never board data — and deleting it is safe: it fills again as the
|
|
24211
|
+
* projects are used.
|
|
24212
|
+
*/
|
|
24213
|
+
/** The file's name inside kanbo's per-user folder. */
|
|
24214
|
+
const REGISTRY_FILE_NAME = "projects.json";
|
|
24215
|
+
/** The file is private to the user, as a binding is. */
|
|
24216
|
+
const REGISTRY_FILE_MODE = 384;
|
|
24217
|
+
/** The folder holding it is private to the user too. */
|
|
24218
|
+
const REGISTRY_DIRECTORY_MODE = 448;
|
|
24219
|
+
/**
|
|
24220
|
+
* A project noted within this many seconds is not noted again, so an ordinary
|
|
24221
|
+
* command does not rewrite the file: a day is fresh enough for "recently used".
|
|
24222
|
+
*/
|
|
24223
|
+
const RECENTLY_NOTED_SECONDS = 86400;
|
|
24224
|
+
const RegisteredProjectSchema = object$1({
|
|
24225
|
+
path: string().min(1),
|
|
24226
|
+
lastSeenAt: number().int().nonnegative()
|
|
24227
|
+
});
|
|
24228
|
+
const RegistrySchema = object$1({
|
|
24229
|
+
schemaVersion: literal(1),
|
|
24230
|
+
projects: array(RegisteredProjectSchema)
|
|
24231
|
+
});
|
|
24232
|
+
/** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
|
|
24233
|
+
function projectRegistryPath(env = process.env) {
|
|
24234
|
+
const home = env.KANBO_HOME?.trim();
|
|
24235
|
+
return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
|
|
24236
|
+
}
|
|
24237
|
+
/** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
|
|
24238
|
+
function readProjectRegistry(path = projectRegistryPath()) {
|
|
24239
|
+
return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
24240
|
+
}
|
|
24241
|
+
/**
|
|
24242
|
+
* Note that `projectDir` was just used. Writes only when the folder is new or
|
|
24243
|
+
* was last noted more than a day ago, so ordinary commands do not rewrite the file.
|
|
24244
|
+
* Drops entries whose `.kanbo/binding.json` is gone.
|
|
24245
|
+
*/
|
|
24246
|
+
function rememberProject(projectDir, options = {}) {
|
|
24247
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
24248
|
+
const path = options.path ?? projectRegistryPath();
|
|
24249
|
+
const canonical = canonicalPath$1(projectDir);
|
|
24250
|
+
const entries = readEntries(path);
|
|
24251
|
+
const known = entries.find((entry) => entry.path === canonical);
|
|
24252
|
+
if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
|
|
24253
|
+
const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
|
|
24254
|
+
kept.push({
|
|
24255
|
+
path: canonical,
|
|
24256
|
+
lastSeenAt: now
|
|
24257
|
+
});
|
|
24258
|
+
writeEntries(path, kept);
|
|
24259
|
+
}
|
|
24260
|
+
/**
|
|
24261
|
+
* `rememberProject`, for the places that must not fail because of it: a home
|
|
24262
|
+
* directory that cannot be written is not a reason for `kanbo card list` to fail.
|
|
24263
|
+
* With `KANBO_DEBUG=1` the reason is printed on stderr.
|
|
24264
|
+
*/
|
|
24265
|
+
function rememberProjectQuietly(projectDir) {
|
|
24266
|
+
const path = projectRegistryPath();
|
|
24267
|
+
try {
|
|
24268
|
+
rememberProject(projectDir, { path });
|
|
24269
|
+
} catch (error) {
|
|
24270
|
+
if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
24271
|
+
}
|
|
24272
|
+
}
|
|
24273
|
+
function canonicalPath$1(projectDir) {
|
|
24274
|
+
const absolute = (0, node_path.resolve)(projectDir);
|
|
24275
|
+
try {
|
|
24276
|
+
return (0, node_fs.realpathSync)(absolute);
|
|
24277
|
+
} catch {
|
|
24278
|
+
return absolute;
|
|
24279
|
+
}
|
|
24280
|
+
}
|
|
24281
|
+
function readEntries(path) {
|
|
24282
|
+
try {
|
|
24283
|
+
const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
|
|
24284
|
+
return parsed.success ? parsed.data.projects : [];
|
|
24285
|
+
} catch {
|
|
24286
|
+
return [];
|
|
24287
|
+
}
|
|
24288
|
+
}
|
|
24289
|
+
function writeEntries(path, projects) {
|
|
24290
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
|
|
24291
|
+
recursive: true,
|
|
24292
|
+
mode: REGISTRY_DIRECTORY_MODE
|
|
24293
|
+
});
|
|
24294
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
24295
|
+
try {
|
|
24296
|
+
(0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
|
|
24297
|
+
schemaVersion: 1,
|
|
24298
|
+
projects
|
|
24299
|
+
}), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
|
|
24300
|
+
(0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
|
|
24301
|
+
renameWithRetry(temporaryPath, path);
|
|
24302
|
+
} catch (error) {
|
|
24303
|
+
(0, node_fs.rmSync)(temporaryPath, { force: true });
|
|
24304
|
+
throw error;
|
|
24305
|
+
}
|
|
24306
|
+
}
|
|
24307
|
+
//#endregion
|
|
23779
24308
|
//#region src/postgres/open-database.ts
|
|
23780
24309
|
/** `information_schema.tables`, the first of the two views the guard reads. */
|
|
23781
24310
|
const informationSchemaTables = pgSchema("information_schema").table("tables", {
|
|
@@ -23855,9 +24384,10 @@ const SCHEMA_NOT_INSTALLED_MESSAGE = "This Postgres database has no kanbo board
|
|
|
23855
24384
|
* caller's own risk but never written to — a write shaped by this build's idea
|
|
23856
24385
|
* of the schema is how a person's board gets a column full of nothing. A file
|
|
23857
24386
|
* of this package's own is this package's to migrate, so the same refusal
|
|
23858
|
-
* points at `kanbo migrate` instead of at the app. Reads
|
|
23859
|
-
*
|
|
23860
|
-
* their cards
|
|
24387
|
+
* points at `kanbo migrate` instead of at the app. Reads are held to the lesser
|
|
24388
|
+
* bar of `assertReadableBoard`: a file only a stamp behind is still read, so a
|
|
24389
|
+
* person who cannot migrate right now can look at their cards, but one missing
|
|
24390
|
+
* a table or column this build reads is refused with the same sentence.
|
|
23861
24391
|
*/
|
|
23862
24392
|
function assertWritableBoard(database, owner) {
|
|
23863
24393
|
try {
|
|
@@ -23868,6 +24398,26 @@ function assertWritableBoard(database, owner) {
|
|
|
23868
24398
|
}
|
|
23869
24399
|
}
|
|
23870
24400
|
/**
|
|
24401
|
+
* Refuse a read of a board file that lacks a table or a column this build
|
|
24402
|
+
* reads — a file a migration behind — with the sentence a write gets, instead
|
|
24403
|
+
* of letting the first statement fail on what is missing. A file whose only
|
|
24404
|
+
* difference is its schema stamp is still read: every statement this build
|
|
24405
|
+
* makes works on it, and a person who cannot migrate now can still look at
|
|
24406
|
+
* their cards.
|
|
24407
|
+
*/
|
|
24408
|
+
function assertReadableBoard(database, owner) {
|
|
24409
|
+
try {
|
|
24410
|
+
assertBoardSchema$1(database);
|
|
24411
|
+
} catch (error) {
|
|
24412
|
+
if (!(error instanceof BoardError && error.code === "board_schema_outdated")) throw error;
|
|
24413
|
+
if (lacksWhatThisBuildReads(error.details)) throw new CliError(3, owner === "kanbo" ? FILE_SCHEMA_OUTDATED_MESSAGE : SCHEMA_OUTDATED_MESSAGE);
|
|
24414
|
+
}
|
|
24415
|
+
}
|
|
24416
|
+
/** Whether `assertBoardSchema` found a table or a column missing, rather than only another stamp. */
|
|
24417
|
+
function lacksWhatThisBuildReads(details) {
|
|
24418
|
+
return details !== void 0 && ("missingTables" in details || "missingAddedTables" in details || "missingColumns" in details);
|
|
24419
|
+
}
|
|
24420
|
+
/**
|
|
23871
24421
|
* Refuse an external board this build cannot speak for — before reading it as
|
|
23872
24422
|
* well as before writing to it.
|
|
23873
24423
|
*
|
|
@@ -24087,15 +24637,17 @@ function withBoardOptions(command) {
|
|
|
24087
24637
|
* as a client is talking to it. A failure on the way out closes the board first
|
|
24088
24638
|
* — a caller that never received a session has nothing to close.
|
|
24089
24639
|
*/
|
|
24090
|
-
async function openBoardSession(options, mode) {
|
|
24091
|
-
const cwd = process.cwd();
|
|
24640
|
+
async function openBoardSession(options, mode, cwd = process.cwd()) {
|
|
24092
24641
|
const target = resolveDbTarget({
|
|
24093
24642
|
explicitPath: options.db,
|
|
24094
24643
|
explicitUrl: options.databaseUrl,
|
|
24095
24644
|
asAgent: options.asAgent,
|
|
24096
24645
|
cwd
|
|
24097
24646
|
});
|
|
24098
|
-
|
|
24647
|
+
const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
|
|
24648
|
+
const found = findBinding(cwd);
|
|
24649
|
+
if (found) rememberProjectQuietly(found.projectDir);
|
|
24650
|
+
return session;
|
|
24099
24651
|
}
|
|
24100
24652
|
async function openFileSession(target, options, mode, cwd) {
|
|
24101
24653
|
const board = await openBoardDatabase(target.path);
|
|
@@ -24104,6 +24656,7 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
24104
24656
|
assertWritableBoard(board.database, target.owner);
|
|
24105
24657
|
};
|
|
24106
24658
|
if (mode === "write") assertWritableBoard(board.database, target.owner);
|
|
24659
|
+
else assertReadableBoard(board.database, target.owner);
|
|
24107
24660
|
const workspace = resolveWorkspace(target.owner === "kanbo" ? { kind: "file" } : {
|
|
24108
24661
|
kind: "sqlite",
|
|
24109
24662
|
database: board.database
|
|
@@ -24127,8 +24680,9 @@ async function openFileSession(target, options, mode, cwd) {
|
|
|
24127
24680
|
/**
|
|
24128
24681
|
* The same, on a board that is a database of its own.
|
|
24129
24682
|
*
|
|
24130
|
-
* The guard runs whatever the command came to do. A board file
|
|
24131
|
-
*
|
|
24683
|
+
* The guard runs whatever the command came to do. A board file whose only
|
|
24684
|
+
* difference from this build is its schema stamp is still a board worth
|
|
24685
|
+
* reading; a connection string pointing
|
|
24132
24686
|
* at a database with no board in it is nothing to read at all, and every
|
|
24133
24687
|
* command would otherwise fail one statement later with the driver's own words.
|
|
24134
24688
|
*/
|
|
@@ -24426,7 +24980,9 @@ function projectCardActiveRun(run) {
|
|
|
24426
24980
|
startedAt: run.startedAt
|
|
24427
24981
|
};
|
|
24428
24982
|
}
|
|
24429
|
-
|
|
24983
|
+
/** A column as the command line prints it; `board` is every column, to name the ones it accepts cards from. */
|
|
24984
|
+
function projectColumn(column, board) {
|
|
24985
|
+
const { allowedFrom, wipLimit } = readFlowRules(column);
|
|
24430
24986
|
return {
|
|
24431
24987
|
id: column.id,
|
|
24432
24988
|
name: column.name,
|
|
@@ -24434,7 +24990,9 @@ function projectColumn(column) {
|
|
|
24434
24990
|
description: column.description,
|
|
24435
24991
|
category: column.category,
|
|
24436
24992
|
order: column.order,
|
|
24437
|
-
entryRules: readEntryRules(column)
|
|
24993
|
+
entryRules: readEntryRules(column),
|
|
24994
|
+
allowedFrom: allowedFrom === null ? null : board.filter((other) => allowedFrom.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
24995
|
+
wipLimit
|
|
24438
24996
|
};
|
|
24439
24997
|
}
|
|
24440
24998
|
function projectSprint(sprint) {
|
|
@@ -24506,7 +25064,9 @@ function describeColumns(columns) {
|
|
|
24506
25064
|
return columns.map((column) => [
|
|
24507
25065
|
`${padToWidth(sanitizeTerminalText(column.slug), slugWidth)} ${sanitizeTerminalText(column.name)}`,
|
|
24508
25066
|
column.description ? ` — ${sanitizeTerminalText(column.description)}` : "",
|
|
24509
|
-
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : ""
|
|
25067
|
+
column.entryRules.length > 0 ? ` [requires: ${column.entryRules.join(", ")}]` : "",
|
|
25068
|
+
column.allowedFrom === null ? "" : ` [from: ${column.allowedFrom.length > 0 ? column.allowedFrom.join(", ") : "no column"}]`,
|
|
25069
|
+
column.wipLimit === null ? "" : ` [wip: ${column.wipLimit}]`
|
|
24510
25070
|
].join("")).join("\n");
|
|
24511
25071
|
}
|
|
24512
25072
|
/** A unix-seconds date as the UTC day it falls on, `—` for none. */
|
|
@@ -24700,12 +25260,13 @@ function colorsWanted() {
|
|
|
24700
25260
|
const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
24701
25261
|
kanbo_prime: { kanbo: "kanbo prime" },
|
|
24702
25262
|
kanbo_ready: { kanbo: "kanbo ready" },
|
|
25263
|
+
kanbo_card_claim: { kanbo: "kanbo claim [card] [--agent <name>]" },
|
|
24703
25264
|
kanbo_columns: { kanbo: "kanbo columns list" },
|
|
24704
25265
|
kanbo_sprints: { kanbo: "kanbo sprint list" },
|
|
24705
25266
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
24706
25267
|
kanbo_card_list: { kanbo: "kanbo card list" },
|
|
24707
25268
|
kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
|
|
24708
|
-
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
|
|
25269
|
+
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
|
|
24709
25270
|
kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
|
|
24710
25271
|
kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
|
|
24711
25272
|
kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
|
|
@@ -24903,6 +25464,81 @@ const cardCommentTool = defineKanboTool({
|
|
|
24903
25464
|
},
|
|
24904
25465
|
run: async (transport, input) => await transport.cardComment(input)
|
|
24905
25466
|
});
|
|
25467
|
+
/** The `detail` and `fields` arguments the list tools share. */
|
|
25468
|
+
const cardListViewArguments = {
|
|
25469
|
+
detail: _enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
|
|
25470
|
+
fields: array(_enum([
|
|
25471
|
+
"number",
|
|
25472
|
+
"title",
|
|
25473
|
+
"description",
|
|
25474
|
+
"column",
|
|
25475
|
+
"columnSlug",
|
|
25476
|
+
"statusLine",
|
|
25477
|
+
"waitingFor",
|
|
25478
|
+
"priority",
|
|
25479
|
+
"labels",
|
|
25480
|
+
"executionMode",
|
|
25481
|
+
"parentIssueId",
|
|
25482
|
+
"attemptCount",
|
|
25483
|
+
"activeRun",
|
|
25484
|
+
"updatedAt"
|
|
25485
|
+
])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
|
|
25486
|
+
};
|
|
25487
|
+
function compactKanboCard(card) {
|
|
25488
|
+
return {
|
|
25489
|
+
id: card.id,
|
|
25490
|
+
title: cardDisplayTitle(card),
|
|
25491
|
+
column: card.columnSlug,
|
|
25492
|
+
...card.statusLine ? { statusLine: card.statusLine } : {},
|
|
25493
|
+
...card.waitingFor ? { waitingFor: card.waitingFor } : {},
|
|
25494
|
+
...card.parentIssueId ? { parentId: card.parentIssueId } : {},
|
|
25495
|
+
...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
|
|
25496
|
+
...card.activeRun ? { activeRun: {
|
|
25497
|
+
agentName: card.activeRun.agentName,
|
|
25498
|
+
startedAt: card.activeRun.startedAt
|
|
25499
|
+
} } : {},
|
|
25500
|
+
updatedAt: card.updatedAt,
|
|
25501
|
+
...card.returned ? { returned: card.returned } : {},
|
|
25502
|
+
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
25503
|
+
};
|
|
25504
|
+
}
|
|
25505
|
+
/** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
|
|
25506
|
+
const writtenCardDetailArgument = _enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
|
|
25507
|
+
/** What each tool that writes a card says about its answer, in its description. */
|
|
25508
|
+
const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
|
|
25509
|
+
/**
|
|
25510
|
+
* The card a write hands back, as the tool answers with it: compact and on one
|
|
25511
|
+
* line, as a list prints it. An agent writes its card at every step — a status
|
|
25512
|
+
* line, a move, an update — and reads each answer again on every later turn,
|
|
25513
|
+
* so a whole card, description included, would cost it again and again.
|
|
25514
|
+
* `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
|
|
25515
|
+
*/
|
|
25516
|
+
function formatWrittenKanboCard(card, detail) {
|
|
25517
|
+
return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
|
|
25518
|
+
}
|
|
25519
|
+
function viewCard(card, view) {
|
|
25520
|
+
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
25521
|
+
return view.detail === "full" ? card : compactKanboCard(card);
|
|
25522
|
+
}
|
|
25523
|
+
/**
|
|
25524
|
+
* The page as the tool's answer: how many cards the question picks, where the
|
|
25525
|
+
* next page starts when there is one, and one card per line.
|
|
25526
|
+
*/
|
|
25527
|
+
function formatKanboCardPage(page, view, returned) {
|
|
25528
|
+
const shownUntil = page.offset + page.cards.length;
|
|
25529
|
+
const more = Math.max(0, page.total - shownUntil);
|
|
25530
|
+
const head = {
|
|
25531
|
+
total: page.total,
|
|
25532
|
+
...page.offset > 0 ? { offset: page.offset } : {},
|
|
25533
|
+
...more > 0 ? {
|
|
25534
|
+
more,
|
|
25535
|
+
nextOffset: shownUntil
|
|
25536
|
+
} : {}
|
|
25537
|
+
};
|
|
25538
|
+
const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
|
|
25539
|
+
const opening = `{${returned && returned.cards.length > 0 ? `"returned":[\n${returned.cards.map((card) => JSON.stringify(viewCard(card, view))).join(",\n")}\n],${returned.total > returned.cards.length ? `"returnedTotal":${returned.total},` : ""}` : ""}${JSON.stringify(head).slice(1, -1)}`;
|
|
25540
|
+
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
25541
|
+
}
|
|
24906
25542
|
//#endregion
|
|
24907
25543
|
//#region src/mcp/tools/card-create.ts
|
|
24908
25544
|
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
@@ -24911,19 +25547,20 @@ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pas
|
|
|
24911
25547
|
const cardCreateTool = defineKanboTool({
|
|
24912
25548
|
name: "kanbo_card_create",
|
|
24913
25549
|
title: "Create a card",
|
|
24914
|
-
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}
|
|
25550
|
+
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")} Answers the card compact, as kanbo_card_list prints it; detail: "full" for every field.`,
|
|
24915
25551
|
inputSchema: {
|
|
24916
25552
|
title: string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
24917
25553
|
description: string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
24918
25554
|
column: string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
24919
25555
|
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
24920
|
-
executionMode: executionModeArgument.optional()
|
|
25556
|
+
executionMode: executionModeArgument.optional(),
|
|
25557
|
+
detail: writtenCardDetailArgument
|
|
24921
25558
|
},
|
|
24922
25559
|
example: {
|
|
24923
25560
|
description: "Add slugify(text) with a test",
|
|
24924
25561
|
parent: "TST-1"
|
|
24925
25562
|
},
|
|
24926
|
-
run: async (transport, input) => await transport.cardCreate(input)
|
|
25563
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
|
|
24927
25564
|
});
|
|
24928
25565
|
//#endregion
|
|
24929
25566
|
//#region src/mcp/transport.ts
|
|
@@ -25040,23 +25677,34 @@ function projectKanboSprint(sprint) {
|
|
|
25040
25677
|
doneCards: sprint.cards.done
|
|
25041
25678
|
};
|
|
25042
25679
|
}
|
|
25043
|
-
/** A column of the board, from the row either transport holds. */
|
|
25044
|
-
function projectKanboColumn(column) {
|
|
25680
|
+
/** A column of the board, from the row either transport holds; `board` is every column, to name the ones it accepts cards from. */
|
|
25681
|
+
function projectKanboColumn(column, board) {
|
|
25045
25682
|
const stored = column.entryRules;
|
|
25683
|
+
const allowedIds = typeof column.allowedFrom === "string" ? readFlowRules({
|
|
25684
|
+
allowedFrom: column.allowedFrom,
|
|
25685
|
+
wipLimit: null
|
|
25686
|
+
}).allowedFrom : column.allowedFrom ?? null;
|
|
25046
25687
|
return {
|
|
25047
25688
|
id: column.id,
|
|
25048
25689
|
name: column.name,
|
|
25049
25690
|
slug: normalizeStatusName(column.name),
|
|
25050
25691
|
description: column.description,
|
|
25051
25692
|
order: column.order,
|
|
25052
|
-
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null })
|
|
25693
|
+
entryRules: typeof stored === "object" && stored !== null ? ENTRY_RULES.filter((rule) => stored.includes(rule)) : readEntryRules({ entryRules: stored ?? null }),
|
|
25694
|
+
allowedFrom: allowedIds === null ? null : board.filter((other) => allowedIds.includes(other.id)).map((other) => normalizeStatusName(other.name)),
|
|
25695
|
+
wipLimit: column.wipLimit ?? null
|
|
25053
25696
|
};
|
|
25054
25697
|
}
|
|
25055
25698
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
25056
25699
|
function projectKanboCardComment(comment) {
|
|
25700
|
+
const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
|
|
25701
|
+
const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
|
|
25702
|
+
const named = author !== "system" || decision;
|
|
25703
|
+
const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
|
|
25057
25704
|
return {
|
|
25058
25705
|
id: comment.id,
|
|
25059
|
-
author
|
|
25706
|
+
author,
|
|
25707
|
+
by: named || outside ? comment.authorId ?? author : "kanbo",
|
|
25060
25708
|
content: comment.content,
|
|
25061
25709
|
createdAt: comment.createdAt
|
|
25062
25710
|
};
|
|
@@ -25104,67 +25752,6 @@ const cardLinkPrTool = defineKanboTool({
|
|
|
25104
25752
|
},
|
|
25105
25753
|
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
25106
25754
|
});
|
|
25107
|
-
/** The `detail` and `fields` arguments the list tools share. */
|
|
25108
|
-
const cardListViewArguments = {
|
|
25109
|
-
detail: _enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
|
|
25110
|
-
fields: array(_enum([
|
|
25111
|
-
"number",
|
|
25112
|
-
"title",
|
|
25113
|
-
"description",
|
|
25114
|
-
"column",
|
|
25115
|
-
"columnSlug",
|
|
25116
|
-
"statusLine",
|
|
25117
|
-
"waitingFor",
|
|
25118
|
-
"priority",
|
|
25119
|
-
"labels",
|
|
25120
|
-
"executionMode",
|
|
25121
|
-
"parentIssueId",
|
|
25122
|
-
"attemptCount",
|
|
25123
|
-
"activeRun",
|
|
25124
|
-
"updatedAt"
|
|
25125
|
-
])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
|
|
25126
|
-
};
|
|
25127
|
-
function compactKanboCard(card) {
|
|
25128
|
-
return {
|
|
25129
|
-
id: card.id,
|
|
25130
|
-
title: cardDisplayTitle(card),
|
|
25131
|
-
column: card.columnSlug,
|
|
25132
|
-
...card.statusLine ? { statusLine: card.statusLine } : {},
|
|
25133
|
-
...card.waitingFor ? { waitingFor: card.waitingFor } : {},
|
|
25134
|
-
...card.parentIssueId ? { parentId: card.parentIssueId } : {},
|
|
25135
|
-
...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
|
|
25136
|
-
...card.activeRun ? { activeRun: {
|
|
25137
|
-
agentName: card.activeRun.agentName,
|
|
25138
|
-
startedAt: card.activeRun.startedAt
|
|
25139
|
-
} } : {},
|
|
25140
|
-
updatedAt: card.updatedAt,
|
|
25141
|
-
...card.returned ? { returned: card.returned } : {},
|
|
25142
|
-
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
25143
|
-
};
|
|
25144
|
-
}
|
|
25145
|
-
function viewCard(card, view) {
|
|
25146
|
-
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
25147
|
-
return view.detail === "full" ? card : compactKanboCard(card);
|
|
25148
|
-
}
|
|
25149
|
-
/**
|
|
25150
|
-
* The page as the tool's answer: how many cards the question picks, where the
|
|
25151
|
-
* next page starts when there is one, and one card per line.
|
|
25152
|
-
*/
|
|
25153
|
-
function formatKanboCardPage(page, view, returned) {
|
|
25154
|
-
const shownUntil = page.offset + page.cards.length;
|
|
25155
|
-
const more = Math.max(0, page.total - shownUntil);
|
|
25156
|
-
const head = {
|
|
25157
|
-
total: page.total,
|
|
25158
|
-
...page.offset > 0 ? { offset: page.offset } : {},
|
|
25159
|
-
...more > 0 ? {
|
|
25160
|
-
more,
|
|
25161
|
-
nextOffset: shownUntil
|
|
25162
|
-
} : {}
|
|
25163
|
-
};
|
|
25164
|
-
const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
|
|
25165
|
-
const opening = `{${returned && returned.cards.length > 0 ? `"returned":[\n${returned.cards.map((card) => JSON.stringify(viewCard(card, view))).join(",\n")}\n],${returned.total > returned.cards.length ? `"returnedTotal":${returned.total},` : ""}` : ""}${JSON.stringify(head).slice(1, -1)}`;
|
|
25166
|
-
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
25167
|
-
}
|
|
25168
25755
|
/** The cards on the board a question picks, one page of them. */
|
|
25169
25756
|
const cardListTool = defineKanboTool({
|
|
25170
25757
|
name: "kanbo_card_list",
|
|
@@ -25219,10 +25806,11 @@ function readMoment(value) {
|
|
|
25219
25806
|
const cardMoveTool = defineKanboTool({
|
|
25220
25807
|
name: "kanbo_card_move",
|
|
25221
25808
|
title: "Move a card",
|
|
25222
|
-
description:
|
|
25809
|
+
description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column refuses a card that does not meet its entryRules yet, that comes from a column it does not accept (allowedFrom), or that would overfill it (wipLimit), listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
25223
25810
|
inputSchema: {
|
|
25224
25811
|
card: cardArgument,
|
|
25225
|
-
column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
|
|
25812
|
+
column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
25813
|
+
detail: writtenCardDetailArgument
|
|
25226
25814
|
},
|
|
25227
25815
|
aliases: { column: ["to"] },
|
|
25228
25816
|
needs: { column: "the column slug, e.g. in_progress" },
|
|
@@ -25230,7 +25818,7 @@ const cardMoveTool = defineKanboTool({
|
|
|
25230
25818
|
card: "TST-5",
|
|
25231
25819
|
column: "in_progress"
|
|
25232
25820
|
},
|
|
25233
|
-
run: async (transport, input) => await transport.cardMove(input)
|
|
25821
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
|
|
25234
25822
|
});
|
|
25235
25823
|
//#endregion
|
|
25236
25824
|
//#region src/mcp/tools/card-pull-requests.ts
|
|
@@ -25245,11 +25833,11 @@ const cardPullRequestsTool = defineKanboTool({
|
|
|
25245
25833
|
});
|
|
25246
25834
|
//#endregion
|
|
25247
25835
|
//#region src/mcp/tools/card-update.ts
|
|
25248
|
-
/** What a card says
|
|
25836
|
+
/** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
|
|
25249
25837
|
const cardUpdateTool = defineKanboTool({
|
|
25250
25838
|
name: "kanbo_card_update",
|
|
25251
25839
|
title: "Update a card",
|
|
25252
|
-
description:
|
|
25840
|
+
description: `Change what a card says: its title, its description, its priority, its labels or where its work happens. A card at the wrong level moves with \`parent\`: another card's id, or "none" for the top level. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
25253
25841
|
inputSchema: {
|
|
25254
25842
|
card: cardArgument,
|
|
25255
25843
|
title: string().min(1).optional().describe("What the card is called."),
|
|
@@ -25262,18 +25850,26 @@ const cardUpdateTool = defineKanboTool({
|
|
|
25262
25850
|
"urgent"
|
|
25263
25851
|
]).optional(),
|
|
25264
25852
|
labels: array(string()).optional().describe("The labels the card carries, replacing the ones it has."),
|
|
25265
|
-
executionMode: executionModeArgument.optional()
|
|
25853
|
+
executionMode: executionModeArgument.optional(),
|
|
25854
|
+
parent: cardArgument.optional().describe("The card to put this one under, by its id or key, e.g. MAN-012; \"none\" makes it a top-level card."),
|
|
25855
|
+
detail: writtenCardDetailArgument
|
|
25266
25856
|
},
|
|
25267
25857
|
example: {
|
|
25268
25858
|
card: "TST-5",
|
|
25269
25859
|
title: "Add slugify"
|
|
25270
25860
|
},
|
|
25271
|
-
|
|
25272
|
-
|
|
25273
|
-
return
|
|
25861
|
+
prepare: (input) => {
|
|
25862
|
+
const { parent, ...rest } = input;
|
|
25863
|
+
return parent === null ? rest : input;
|
|
25864
|
+
},
|
|
25865
|
+
run: async (transport, { card, detail, parent, ...patch }) => {
|
|
25866
|
+
if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
|
|
25867
|
+
const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
|
|
25868
|
+
return formatWrittenKanboCard(await transport.cardUpdate({
|
|
25274
25869
|
card,
|
|
25275
|
-
...patch
|
|
25276
|
-
|
|
25870
|
+
...patch,
|
|
25871
|
+
...placement
|
|
25872
|
+
}), detail);
|
|
25277
25873
|
}
|
|
25278
25874
|
});
|
|
25279
25875
|
//#endregion
|
|
@@ -25282,7 +25878,7 @@ const cardUpdateTool = defineKanboTool({
|
|
|
25282
25878
|
const columnsTool = defineKanboTool({
|
|
25283
25879
|
name: "kanbo_columns",
|
|
25284
25880
|
title: "Columns of the board",
|
|
25285
|
-
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here,
|
|
25881
|
+
description: "List this board's columns in board order, each with the slug kanbo_card_move takes, what the column means here, the entryRules a card must meet before you move it in, the columns it accepts cards from (allowedFrom, slugs; null for any) and the most cards it holds (wipLimit).",
|
|
25286
25882
|
inputSchema: {},
|
|
25287
25883
|
run: async (transport) => await transport.columns()
|
|
25288
25884
|
});
|
|
@@ -25296,6 +25892,38 @@ const primeTool = defineKanboTool({
|
|
|
25296
25892
|
inputSchema: {},
|
|
25297
25893
|
run: async (transport) => (await transport.prime()).text
|
|
25298
25894
|
});
|
|
25895
|
+
//#endregion
|
|
25896
|
+
//#region src/mcp/tools/card-claim.ts
|
|
25897
|
+
/**
|
|
25898
|
+
* Taking a card to work on, in one step: the run starts and the card moves out
|
|
25899
|
+
* of To Do into the board's first work-in-progress column in the same write,
|
|
25900
|
+
* so two agents never get the same card. Published to every host, including
|
|
25901
|
+
* one that records the runs of the agents it launches: a claim is how an agent
|
|
25902
|
+
* in any terminal takes work.
|
|
25903
|
+
*/
|
|
25904
|
+
const cardClaimTool = defineKanboTool({
|
|
25905
|
+
name: "kanbo_card_claim",
|
|
25906
|
+
title: "Claim a card",
|
|
25907
|
+
description: "Take a card to work on, in one step: it starts your run and moves the card from To Do into the board's first work-in-progress column. Without `card`, takes the first ready card. Two agents never get the same card: the one that loses is told board_nothing_ready or board_card_not_ready. Answers the card compact, the run id, the column, and the kanbo_run_finish call that ends the run.",
|
|
25908
|
+
inputSchema: {
|
|
25909
|
+
card: cardArgument.optional(),
|
|
25910
|
+
agent: string().min(1).optional().describe("What to call whoever works the card; who this server writes as when absent.")
|
|
25911
|
+
},
|
|
25912
|
+
aliases: {
|
|
25913
|
+
card: [...CARD_ALIASES, "issue"],
|
|
25914
|
+
agent: ["agentName"]
|
|
25915
|
+
},
|
|
25916
|
+
example: { card: "TST-5" },
|
|
25917
|
+
run: async (transport, input) => {
|
|
25918
|
+
const claimed = await transport.cardClaim(input);
|
|
25919
|
+
return {
|
|
25920
|
+
card: compactKanboCard(claimed.card),
|
|
25921
|
+
run: claimed.run.id,
|
|
25922
|
+
column: claimed.column.slug,
|
|
25923
|
+
finishWith: `kanbo_run_finish {"run":"${claimed.run.id}","state":"finished"}`
|
|
25924
|
+
};
|
|
25925
|
+
}
|
|
25926
|
+
});
|
|
25299
25927
|
/** The cards an agent may pick up right now. */
|
|
25300
25928
|
const readyTool = defineKanboTool({
|
|
25301
25929
|
name: "kanbo_ready",
|
|
@@ -25384,13 +26012,14 @@ const runStartTool = defineKanboTool({
|
|
|
25384
26012
|
/**
|
|
25385
26013
|
* Every tool the board has, in the order it declares them.
|
|
25386
26014
|
*
|
|
25387
|
-
* The list is the board's, not a server's: the same
|
|
26015
|
+
* The list is the board's, not a server's: the same seventeen are registered
|
|
25388
26016
|
* whether they run against a server or against the board directly, and the
|
|
25389
26017
|
* only thing a host decides is which of them it publishes.
|
|
25390
26018
|
*/
|
|
25391
26019
|
const KANBO_TOOLS = [
|
|
25392
26020
|
primeTool,
|
|
25393
26021
|
readyTool,
|
|
26022
|
+
cardClaimTool,
|
|
25394
26023
|
columnsTool,
|
|
25395
26024
|
defineKanboTool({
|
|
25396
26025
|
name: "kanbo_sprints",
|
|
@@ -25410,10 +26039,11 @@ const KANBO_TOOLS = [
|
|
|
25410
26039
|
defineKanboTool({
|
|
25411
26040
|
name: "kanbo_status_line",
|
|
25412
26041
|
title: "Set the status line",
|
|
25413
|
-
description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running.",
|
|
26042
|
+
description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running. " + WRITTEN_CARD_ANSWER_SENTENCE,
|
|
25414
26043
|
inputSchema: {
|
|
25415
26044
|
card: cardArgument,
|
|
25416
|
-
text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
|
|
26045
|
+
text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
|
|
26046
|
+
detail: writtenCardDetailArgument
|
|
25417
26047
|
},
|
|
25418
26048
|
aliases: { text: ["content"] },
|
|
25419
26049
|
needs: { text: "the status line, one sentence" },
|
|
@@ -25421,22 +26051,23 @@ const KANBO_TOOLS = [
|
|
|
25421
26051
|
card: "TST-5",
|
|
25422
26052
|
text: "Running the tests"
|
|
25423
26053
|
},
|
|
25424
|
-
run: async (transport, input) => await transport.statusLine(input)
|
|
26054
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
|
|
25425
26055
|
}),
|
|
25426
26056
|
defineKanboTool({
|
|
25427
26057
|
name: "kanbo_wait_approval",
|
|
25428
26058
|
title: "Hand the card to a person",
|
|
25429
|
-
description:
|
|
26059
|
+
description: `Mark the card as waiting for a person and end your turn: \`card\`, and \`text\` for the status line the person reads. A person approves or returns it; you will be told the answer. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
25430
26060
|
inputSchema: {
|
|
25431
26061
|
card: cardArgument,
|
|
25432
|
-
text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
|
|
26062
|
+
text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
|
|
26063
|
+
detail: writtenCardDetailArgument
|
|
25433
26064
|
},
|
|
25434
26065
|
aliases: { text: ["content"] },
|
|
25435
26066
|
example: {
|
|
25436
26067
|
card: "TST-5",
|
|
25437
26068
|
text: "Ready for review: slugify added with a test"
|
|
25438
26069
|
},
|
|
25439
|
-
run: async (transport, input) => await transport.waitApproval(input)
|
|
26070
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
|
|
25440
26071
|
}),
|
|
25441
26072
|
runStartTool,
|
|
25442
26073
|
runFinishTool
|
|
@@ -25778,10 +26409,12 @@ function buildCapabilitiesManifest() {
|
|
|
25778
26409
|
"Only a person may close a sprint. `kanbo sprint close` refuses an agent's shell (exit 4), and the operation refuses any actor but a person; there is no MCP tool that closes one — `kanbo_sprints` only reads. Unlike approval, the database role does not enforce this.",
|
|
25779
26410
|
`The agent role (\`${BOARD_AGENT_ROLE}\`) may not delete a card at all — it holds select, insert and update on \`issues\` and nothing more, even though it may delete freely on every other board table.`,
|
|
25780
26411
|
"A column may ask a card to meet entry rules before it enters (`entryRules`). Anyone but a person moving, creating or bulk-moving a card into it early is refused with `board_column_rules_unmet` and one line per unmet rule (CLI exit 1); a person goes through and is told what is missing. Only a person sets the rules — `kanbo columns rules` refuses an agent's shell (exit 4). The database role does not enforce them.",
|
|
26412
|
+
"A column may name the columns it accepts cards from (`allowedFrom`) and the most cards it holds (`wipLimit`). Anyone but a person moving, creating, bulk-moving or claiming a card against them is refused with `board_column_rules_unmet` (`allowed_from`, `wip_limit`); a person goes through and is told. A new board's Done accepts cards from In Review only. Only a person sets them — `kanbo columns flow` refuses an agent's shell (exit 4). The database role does not enforce them.",
|
|
26413
|
+
"A claim (`kanbo claim`, `kanbo_card_claim`, `POST /issues/claim`) takes a ready card from To Do, starts its run and moves it into the board's first `started` column in one write; two claimers never get the same card, and the one that loses is told `board_nothing_ready` or `board_card_not_ready`. A card has one run going at a time: starting a second is refused with `board_card_run_active`, on every path.",
|
|
25781
26414
|
"Only a person changes the board's columns: `kanbo columns add`, `rename`, `move`, `remove`, `template` and `add-standard` refuse an agent's shell (exit 4), and the rename, move, remove and add-missing operations refuse any actor but a person (`board_column_structure_requires_user`); no MCP tool changes columns. To Do can be neither removed nor renamed away from its slug `to_do`: `kanbo ready` takes work from it.",
|
|
25782
26415
|
"No board tool or command deletes a card, agent role or not. Cancelling one is a move to another column. The one exception is `kanbo serve`'s `DELETE /issues/:id`, which it answers only for a request presenting its token, on a server a person started, that has not sent `x-kanbo-actor: agent` (403 `issue_delete_requires_user` otherwise).",
|
|
25783
26416
|
"`kanbo migrate` and `kanbo roles apply` refuse a host app's own database file: the app migrates it, and this package never touches it.",
|
|
25784
|
-
"A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it)
|
|
26417
|
+
"A write to a board older than this build's schema epoch is refused (exit 3) until `kanbo migrate` runs (on a host app's database, until the app migrates it), and so is a read of a board file that lacks a table or a column this build reads; a board whose only difference is its schema stamp stays readable.",
|
|
25785
26418
|
"There is no MCP tool that approves a card, and there never will be: `kanbo_wait_approval` hands the card to a person and ends the turn; only a person takes it back."
|
|
25786
26419
|
]
|
|
25787
26420
|
};
|
|
@@ -25926,6 +26559,52 @@ function registerCapabilitiesCommand(program) {
|
|
|
25926
26559
|
});
|
|
25927
26560
|
}
|
|
25928
26561
|
//#endregion
|
|
26562
|
+
//#region src/cli/commands/claim.ts
|
|
26563
|
+
/**
|
|
26564
|
+
* `kanbo claim [card]` — take a card to work on, in one step.
|
|
26565
|
+
*
|
|
26566
|
+
* The run starts and the card moves out of To Do into the board's first
|
|
26567
|
+
* work-in-progress column in one write, so two agents in two terminals never
|
|
26568
|
+
* take the same card. Filed as a placement: in a person's own terminal the
|
|
26569
|
+
* column's rules warn, in an agent's shell they refuse.
|
|
26570
|
+
*/
|
|
26571
|
+
function registerClaimCommand(program) {
|
|
26572
|
+
withBoardOptions(program.command("claim").description("Take a card to work on: start your run and move it out of To Do, in one step").argument("[card]", "the card, as MAN-012, MAN-12 or 12 (default: the first ready card)").option("--agent <name>", "your name, e.g. claude (default: the name this shell writes as)").option("--session <ref>", "your session, to find it later: claude:<session id> or codex:<session id> (default: the one Claude Code or Codex names in the environment)")).action(async (reference, options) => {
|
|
26573
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
26574
|
+
const actor = createPlacementActor();
|
|
26575
|
+
const named = reference === void 0 ? void 0 : (await requireCard(session, reference)).id;
|
|
26576
|
+
const claimed = await session.ops.claimCard({
|
|
26577
|
+
workspaceId: session.workspace.id,
|
|
26578
|
+
issueId: named,
|
|
26579
|
+
run: {
|
|
26580
|
+
agentName: options.agent ?? actor.id ?? "agent",
|
|
26581
|
+
externalSessionRef: options.session ?? sessionRefFromEnvironment() ?? void 0,
|
|
26582
|
+
launchedByKind: "external"
|
|
26583
|
+
}
|
|
26584
|
+
}, actor);
|
|
26585
|
+
const [columns, projection, runs] = await Promise.all([
|
|
26586
|
+
session.ops.listColumns(session.workspace.id),
|
|
26587
|
+
session.ops.readBoardProjectionForIssue(claimed.card.id),
|
|
26588
|
+
session.ops.listRuns(claimed.card.id)
|
|
26589
|
+
]);
|
|
26590
|
+
const card = projectCard(claimed.card, columns, projection);
|
|
26591
|
+
for (const item of claimed.card.unmetRules ?? []) console.error(`kanbo: warning: ${card.id} entered "${claimed.column.name}" without ${item.rule}: ${item.detail}`);
|
|
26592
|
+
const run = projectRun({
|
|
26593
|
+
...claimed.run,
|
|
26594
|
+
attempt: runs.find((each) => each.id === claimed.run.id)?.attempt ?? runs.length
|
|
26595
|
+
});
|
|
26596
|
+
return {
|
|
26597
|
+
value: {
|
|
26598
|
+
card,
|
|
26599
|
+
run,
|
|
26600
|
+
column: projectColumn(claimed.column, columns)
|
|
26601
|
+
},
|
|
26602
|
+
text: `Claimed ${card.id}: run ${run.id} started, card moved to ${claimed.column.name}\nWhen you are done: kanbo run finish ${run.id} --state finished`
|
|
26603
|
+
};
|
|
26604
|
+
});
|
|
26605
|
+
});
|
|
26606
|
+
}
|
|
26607
|
+
//#endregion
|
|
25929
26608
|
//#region src/cli/card-extras.ts
|
|
25930
26609
|
async function readCardExtras(session, card, columns, include, commentLimit) {
|
|
25931
26610
|
const wants = new Set(include);
|
|
@@ -25981,7 +26660,7 @@ function describeCardExtras(extras) {
|
|
|
25981
26660
|
})))].join("\n"));
|
|
25982
26661
|
if (extras.comments && extras.comments.length > 0) {
|
|
25983
26662
|
const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
|
|
25984
|
-
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
26663
|
+
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
25985
26664
|
}
|
|
25986
26665
|
if (extras.runs && extras.runs.length > 0) sections.push(["Runs:", ...extras.runs.map((run) => `#${run.attempt} ${run.state} ${sanitizeTerminalText(run.agentName)} ${formatMoment(run.startedAt)} ${run.id}`)].join("\n"));
|
|
25987
26666
|
if (extras.history && extras.history.length > 0) sections.push(["History:", ...extras.history.map((change) => `${formatMoment(change.createdAt)} ${change.actor} ${change.field}: ${sanitizeTerminalText(change.from ?? "—")} → ${sanitizeTerminalText(change.to ?? "—")}`)].join("\n"));
|
|
@@ -26079,10 +26758,11 @@ function registerCardCommands(program) {
|
|
|
26079
26758
|
}, createPlacementActor()));
|
|
26080
26759
|
});
|
|
26081
26760
|
});
|
|
26082
|
-
withBoardOptions(card.command("update").description("Change a card's title, description, priority or
|
|
26761
|
+
withBoardOptions(card.command("update").description("Change a card's title, description, priority, labels or parent").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode).option("--parent <card>", "the card to put it under; none puts it on the top level")).action(async (reference, options) => {
|
|
26083
26762
|
await runBoardCommand(options, "write", async (session) => {
|
|
26084
26763
|
const input = readUpdateInput(options);
|
|
26085
26764
|
const existing = await requireCard(session, reference);
|
|
26765
|
+
if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
|
|
26086
26766
|
return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
|
|
26087
26767
|
});
|
|
26088
26768
|
});
|
|
@@ -26223,9 +26903,10 @@ async function presentCard(session, card) {
|
|
|
26223
26903
|
};
|
|
26224
26904
|
}
|
|
26225
26905
|
/**
|
|
26226
|
-
* The fields `card update` was asked to change
|
|
26227
|
-
*
|
|
26228
|
-
*
|
|
26906
|
+
* The fields `card update` was asked to change — all but the parent, a card
|
|
26907
|
+
* of the board, looked up there once the card itself is. A command that names
|
|
26908
|
+
* none of them is refused rather than obeyed: an empty update still bumps the
|
|
26909
|
+
* board version, and every reader would be told the board changed when it did not.
|
|
26229
26910
|
*/
|
|
26230
26911
|
function readUpdateInput(options) {
|
|
26231
26912
|
const input = {};
|
|
@@ -26234,7 +26915,7 @@ function readUpdateInput(options) {
|
|
|
26234
26915
|
if (options.priority !== void 0) input.priority = options.priority;
|
|
26235
26916
|
if (options.labels !== void 0) input.labels = options.labels;
|
|
26236
26917
|
if (options.executionMode !== void 0) input.executionMode = options.executionMode;
|
|
26237
|
-
if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels
|
|
26918
|
+
if (Object.keys(input).length === 0 && options.parent === void 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels, --execution-mode or --parent.");
|
|
26238
26919
|
return input;
|
|
26239
26920
|
}
|
|
26240
26921
|
function parsePriority(value) {
|
|
@@ -26304,10 +26985,11 @@ const COLUMN_CATEGORIES = [
|
|
|
26304
26985
|
];
|
|
26305
26986
|
/** The whole board's columns after a change, and the sentence that says what changed. */
|
|
26306
26987
|
async function changed(session, message, extra = {}) {
|
|
26307
|
-
const
|
|
26988
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
26989
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
26308
26990
|
return {
|
|
26309
26991
|
value: {
|
|
26310
|
-
...extra,
|
|
26992
|
+
...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, typeof value === "number" || Array.isArray(value) ? value : projectColumn(value, board)])),
|
|
26311
26993
|
columns: views
|
|
26312
26994
|
},
|
|
26313
26995
|
text: `${message}\n\n${describeColumns(views)}`
|
|
@@ -26336,14 +27018,14 @@ async function addColumn(session, request, actor) {
|
|
|
26336
27018
|
category: request.category ?? spec.category,
|
|
26337
27019
|
position: anchor ? request.after !== void 0 ? { after: anchor.id } : { before: anchor.id } : void 0
|
|
26338
27020
|
}, actor);
|
|
26339
|
-
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added:
|
|
27021
|
+
return await changed(session, `Added ${created.name} (slug ${normalizeStatusName(created.name)}).`, { added: created });
|
|
26340
27022
|
}
|
|
26341
27023
|
/** Said after a rename: agents' instructions name no column but To Do, and `kanbo prime` gives them the rest. */
|
|
26342
27024
|
const RENAME_NOTE = "Agents read column names from kanbo prime; rename is picked up there.";
|
|
26343
27025
|
async function renameColumn(session, column, newName, actor) {
|
|
26344
27026
|
const before = await requireNamedColumn(session, column);
|
|
26345
27027
|
const renamed = await session.ops.renameColumn(session.workspace.id, before.id, newName, actor);
|
|
26346
|
-
return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed
|
|
27028
|
+
return await changed(session, before.name === renamed.name ? `${renamed.name} already has that name.` : `Renamed ${before.name} to ${renamed.name} (slug ${normalizeStatusName(renamed.name)}).\n${RENAME_NOTE}`, { renamed });
|
|
26347
27029
|
}
|
|
26348
27030
|
/** `--first`, `--last`, `--before <column>` or `--after <column>` — exactly one. */
|
|
26349
27031
|
function readColumnPosition(options) {
|
|
@@ -26375,7 +27057,7 @@ async function removeColumn(session, column, moveCardsTo, actor) {
|
|
|
26375
27057
|
const result = await session.ops.removeColumn(session.workspace.id, removing.id, { moveCardsTo }, actor);
|
|
26376
27058
|
for (const card of result.unmetRules ?? []) for (const item of card.unmet) console.error(`kanbo: warning: ${card.issueId} entered "${result.movedTo?.name ?? "—"}" without ${item.rule}: ${item.detail}`);
|
|
26377
27059
|
return await changed(session, result.movedTo ? `Removed ${result.removed.name} and moved ${describeCount(result.movedCards)} to ${result.movedTo.name}.` : `Removed ${result.removed.name}.`, {
|
|
26378
|
-
removed:
|
|
27060
|
+
removed: result.removed,
|
|
26379
27061
|
movedCards: result.movedCards
|
|
26380
27062
|
});
|
|
26381
27063
|
}
|
|
@@ -26499,7 +27181,7 @@ async function runColumnsMenu(ui) {
|
|
|
26499
27181
|
if (showList) say$2(ui, [
|
|
26500
27182
|
"",
|
|
26501
27183
|
"Columns of this board:",
|
|
26502
|
-
describeColumns(columns.map(projectColumn))
|
|
27184
|
+
describeColumns(columns.map((column) => projectColumn(column, columns)))
|
|
26503
27185
|
].join("\n"));
|
|
26504
27186
|
let choice;
|
|
26505
27187
|
try {
|
|
@@ -26727,7 +27409,8 @@ function registerColumnsCommands(program) {
|
|
|
26727
27409
|
const columns = program.command("columns").description("See and change the board's columns (alone: a menu)");
|
|
26728
27410
|
withBoardOptions(columns.command("list").description("List the columns in order, with the short name to move cards by")).action(async (options) => {
|
|
26729
27411
|
await runBoardCommand(options, "read", async (session) => {
|
|
26730
|
-
const
|
|
27412
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
27413
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
26731
27414
|
return {
|
|
26732
27415
|
value: views,
|
|
26733
27416
|
text: describeColumns(views)
|
|
@@ -26736,7 +27419,7 @@ function registerColumnsCommands(program) {
|
|
|
26736
27419
|
});
|
|
26737
27420
|
withBoardOptions(columns.command("describe").description("Say in one line when a card belongs in a column").argument("<column>", "the column, by slug, name or id").requiredOption("--text <text>", "what the column means")).action(async (column, options) => {
|
|
26738
27421
|
await runBoardCommand(options, "write", async (session) => {
|
|
26739
|
-
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text));
|
|
27422
|
+
const view = projectColumn(await session.ops.describeColumn(session.workspace.id, column, options.text), await session.ops.listColumns(session.workspace.id));
|
|
26740
27423
|
return {
|
|
26741
27424
|
value: view,
|
|
26742
27425
|
text: describeColumns([view])
|
|
@@ -26747,13 +27430,34 @@ function registerColumnsCommands(program) {
|
|
|
26747
27430
|
const actor = requireHumanActor("columns rules", typedWords(command));
|
|
26748
27431
|
const rules = readRequestedRules(options);
|
|
26749
27432
|
await runBoardCommand(options, "write", async (session) => {
|
|
26750
|
-
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor));
|
|
27433
|
+
const view = projectColumn(await session.ops.setColumnEntryRules(session.workspace.id, column, rules, actor), await session.ops.listColumns(session.workspace.id));
|
|
26751
27434
|
return {
|
|
26752
27435
|
value: view,
|
|
26753
27436
|
text: describeColumns([view])
|
|
26754
27437
|
};
|
|
26755
27438
|
});
|
|
26756
27439
|
});
|
|
27440
|
+
withBoardOptions(columns.command("flow").description("Set which columns a card may come from, and how many cards a column holds").argument("<column>", "the column, by slug, name or id").option("--from <columns>", "comma-separated columns a card may enter it from, or any").option("--wip <count>", "the most cards it holds, or none")).action(async (column, options, command) => {
|
|
27441
|
+
const actor = requireHumanActor("columns flow", typedWords(command));
|
|
27442
|
+
if (options.from === void 0 && options.wip === void 0) throw new CliError(1, "Pass --from <columns|any>, --wip <count|none>, or both.");
|
|
27443
|
+
const wipLimit = options.wip === void 0 ? void 0 : readWipLimit(options.wip);
|
|
27444
|
+
await runBoardCommand(options, "write", async (session) => {
|
|
27445
|
+
const workspaceId = session.workspace.id;
|
|
27446
|
+
const board = await session.ops.listColumns(workspaceId);
|
|
27447
|
+
const target = await session.ops.requireColumn(workspaceId, column);
|
|
27448
|
+
const current = readFlowRules(target);
|
|
27449
|
+
await session.ops.setColumnFlowRules(target.id, {
|
|
27450
|
+
allowedFrom: options.from === void 0 ? current.allowedFrom : readAllowedFrom(board, workspaceId, options.from),
|
|
27451
|
+
wipLimit: wipLimit === void 0 ? current.wipLimit : wipLimit
|
|
27452
|
+
}, actor);
|
|
27453
|
+
const after = await session.ops.listColumns(workspaceId);
|
|
27454
|
+
const views = after.map((each) => projectColumn(each, after));
|
|
27455
|
+
return {
|
|
27456
|
+
value: views.find((view) => view.id === target.id),
|
|
27457
|
+
text: describeColumns(views)
|
|
27458
|
+
};
|
|
27459
|
+
});
|
|
27460
|
+
});
|
|
26757
27461
|
withBoardOptions(columns.command("add").description("Add a column (before Done unless you say where)").argument("<name>", "what the column is called").option("--after <column>", "put it right after this column").option("--before <column>", "put it right before this column").option("--description <text>", "when a card belongs in it, in one line agents read").addOption(new Option("--category <category>", "what kind of column it is").choices(COLUMN_CATEGORIES))).action(async (name, options, command) => {
|
|
26758
27462
|
const actor = requireHumanActor("columns add", typedWords(command));
|
|
26759
27463
|
await runBoardCommand(options, "write", async (session) => await addColumn(session, {
|
|
@@ -26795,7 +27499,7 @@ function registerColumnsCommands(program) {
|
|
|
26795
27499
|
mode: "add-missing",
|
|
26796
27500
|
actor
|
|
26797
27501
|
});
|
|
26798
|
-
const views = statuses.map(projectColumn);
|
|
27502
|
+
const views = statuses.map((column) => projectColumn(column, statuses));
|
|
26799
27503
|
return {
|
|
26800
27504
|
value: {
|
|
26801
27505
|
added,
|
|
@@ -26819,7 +27523,8 @@ function registerColumnsCommands(program) {
|
|
|
26819
27523
|
return;
|
|
26820
27524
|
}
|
|
26821
27525
|
await runBoardCommand({}, "read", async (session) => {
|
|
26822
|
-
const
|
|
27526
|
+
const board = await session.ops.listColumns(session.workspace.id);
|
|
27527
|
+
const views = board.map((column) => projectColumn(column, board));
|
|
26823
27528
|
return {
|
|
26824
27529
|
value: views,
|
|
26825
27530
|
text: describeColumns(views)
|
|
@@ -26849,6 +27554,26 @@ function readRequestedRules(options) {
|
|
|
26849
27554
|
if (options.require === void 0) throw new CliError(1, `Pass --require ${ENTRY_RULES.join(",")} (any of them) or --clear.`);
|
|
26850
27555
|
return options.require.split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
26851
27556
|
}
|
|
27557
|
+
/** `--from`: `any` for every column, else the columns named, by slug, name or id. */
|
|
27558
|
+
function readAllowedFrom(board, workspaceId, value) {
|
|
27559
|
+
if (value.trim().toLowerCase() === "any") return null;
|
|
27560
|
+
const names = value.split(",").map((name) => name.trim()).filter(Boolean);
|
|
27561
|
+
if (names.length === 0) throw new CliError(1, "Name the columns a card may come from (--from in_review,qa), or --from any.");
|
|
27562
|
+
return names.map((name) => {
|
|
27563
|
+
const found = matchColumn(board, workspaceId, name);
|
|
27564
|
+
if (!found) throw new BoardError("issue_status_not_found", {
|
|
27565
|
+
workspaceId,
|
|
27566
|
+
statusName: name
|
|
27567
|
+
});
|
|
27568
|
+
return found.id;
|
|
27569
|
+
});
|
|
27570
|
+
}
|
|
27571
|
+
/** `--wip`: `none` for no limit, else a whole number of cards, 1 or more. */
|
|
27572
|
+
function readWipLimit(value) {
|
|
27573
|
+
if (value.trim().toLowerCase() === "none") return null;
|
|
27574
|
+
if (!/^\d+$/.test(value.trim()) || Number(value) < 1) throw new CliError(1, "--wip takes a whole number of cards, 1 or more, or none.");
|
|
27575
|
+
return Number(value);
|
|
27576
|
+
}
|
|
26852
27577
|
//#endregion
|
|
26853
27578
|
//#region node_modules/zod/v3/helpers/util.js
|
|
26854
27579
|
var util;
|
|
@@ -42791,7 +43516,7 @@ const INSTRUCTION_END_PATTERN = /<!-- KANBO_END\s*-->/g;
|
|
|
42791
43516
|
const POINTER_KINDS = /* @__PURE__ */ new Set(["short", "global"]);
|
|
42792
43517
|
/** The commands an agent uses, spelled once for the guide and the `kanbo prime` cheat sheet. */
|
|
42793
43518
|
const COMMAND = {
|
|
42794
|
-
|
|
43519
|
+
claim: "kanbo claim",
|
|
42795
43520
|
move: "kanbo card move <id> <column>",
|
|
42796
43521
|
statusLine: "kanbo card status-line <id> --text \"...\"",
|
|
42797
43522
|
subtask: "kanbo card create --description \"...\" --parent <id>",
|
|
@@ -42810,7 +43535,7 @@ const AGENT_GUIDE_TEXT = [
|
|
|
42810
43535
|
"Work on this project is tracked on a kanbo board. Before starting a task run `kanbo prime` — it prints the board's columns and what each one means.",
|
|
42811
43536
|
"",
|
|
42812
43537
|
...[
|
|
42813
|
-
`- Take a card
|
|
43538
|
+
`- Take a card with \`${COMMAND.claim}\`: it starts your run and moves the card into work in one step, and no other agent gets the same card.`,
|
|
42814
43539
|
`- ${MOVE_CARD_RULE} (\`${COMMAND.move}\`)`,
|
|
42815
43540
|
`- ${STATUS_LINE_RULE} (\`${COMMAND.statusLine}\`)`,
|
|
42816
43541
|
`- ${SUBTASK_RULE} (\`${COMMAND.subtask}\`)`,
|
|
@@ -42838,13 +43563,15 @@ const COMMAND_SHEET = [
|
|
|
42838
43563
|
"- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
|
|
42839
43564
|
"- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
|
|
42840
43565
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
43566
|
+
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
42841
43567
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
42842
|
-
"- `kanbo
|
|
43568
|
+
"- `kanbo claim` — take the first ready card: starts your run and moves it into work in one step (`kanbo claim TST-5` for that card); it prints the finish command",
|
|
43569
|
+
"- `kanbo run start TST-5 --agent claude` — record a run on a card you did not claim; it prints the finish command",
|
|
42843
43570
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
42844
43571
|
`- \`${COMMAND.capabilities}\` — everything else, rarely needed`
|
|
42845
43572
|
].join("\n");
|
|
42846
43573
|
/** How to take work and who approves it — said by both blocks after their first sentence. */
|
|
42847
|
-
const POINTER_TAIL = `Take work with \`${COMMAND.
|
|
43574
|
+
const POINTER_TAIL = `Take work with \`${COMMAND.claim}\`. ${PERSON_ONLY_RULE}`;
|
|
42848
43575
|
/** The body of the project block: what `kanbo instructions short` prints. */
|
|
42849
43576
|
const PROJECT_BODY = [
|
|
42850
43577
|
"## Kanbo board",
|
|
@@ -42864,7 +43591,7 @@ const GLOBAL_BODY = [
|
|
|
42864
43591
|
* The block, markers included, with this body's version and hash on the start
|
|
42865
43592
|
* marker — and, when given, the kind of text it is (`k=orchestrator`).
|
|
42866
43593
|
*/
|
|
42867
|
-
function wrapInstructionBlock(body, version =
|
|
43594
|
+
function wrapInstructionBlock(body, version = 3, kind) {
|
|
42868
43595
|
return `<!-- KANBO_START v${version}${kind ? ` k=${kind}` : ""} h=${hashBody(body)} -->\n${body}\n${INSTRUCTION_END}`;
|
|
42869
43596
|
}
|
|
42870
43597
|
/** The block `kanbo init --instructions …` writes into a project's own file. */
|
|
@@ -42893,7 +43620,7 @@ function inspectBlock(text, current) {
|
|
|
42893
43620
|
kind
|
|
42894
43621
|
});
|
|
42895
43622
|
if (fields.trim() === "") return inspection(LEGACY_BLOCK_BODIES.includes(body) ? "legacy" : "edited");
|
|
42896
|
-
if (version !== void 0 && Number(version) >
|
|
43623
|
+
if (version !== void 0 && Number(version) > 3) return inspection("newer");
|
|
42897
43624
|
if (kind !== null && !POINTER_KINDS.has(kind)) return inspection("chosen");
|
|
42898
43625
|
if (version === void 0 || hash === void 0 || hashBody(body) !== hash) return inspection("edited");
|
|
42899
43626
|
return inspection(normalized.slice(found.start, found.end) === current ? "current" : "outdated");
|
|
@@ -43965,7 +44692,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
43965
44692
|
setTimeout: globalThis.setTimeout,
|
|
43966
44693
|
clearTimeout: globalThis.clearTimeout
|
|
43967
44694
|
};
|
|
43968
|
-
const current = deps.currentVersion ?? "0.
|
|
44695
|
+
const current = deps.currentVersion ?? "0.5.0";
|
|
43969
44696
|
const controller = new AbortController();
|
|
43970
44697
|
let found = null;
|
|
43971
44698
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -45287,6 +46014,7 @@ async function applyInitPlan(plan) {
|
|
|
45287
46014
|
}
|
|
45288
46015
|
if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
|
|
45289
46016
|
const bindingPath = writeBinding(plan.projectDir, plan.binding);
|
|
46017
|
+
rememberProjectQuietly(plan.projectDir);
|
|
45290
46018
|
const files = plan.fileChanges.map(({ kind, change }) => ({
|
|
45291
46019
|
kind,
|
|
45292
46020
|
...applyFileChange(change)
|
|
@@ -46034,7 +46762,7 @@ async function planAgents(projectDir, agents, options) {
|
|
|
46034
46762
|
}
|
|
46035
46763
|
/** The board this project is bound to, and the workspace on it. */
|
|
46036
46764
|
async function resolveBoard(projectDir, options) {
|
|
46037
|
-
if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
46765
|
+
if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
46038
46766
|
const target = resolveInitTarget(projectDir, options);
|
|
46039
46767
|
if (target.kind === "postgres") return {
|
|
46040
46768
|
target,
|
|
@@ -46053,7 +46781,7 @@ async function resolveBoard(projectDir, options) {
|
|
|
46053
46781
|
* does — a person on a machine with nothing else installed gets a board, not a
|
|
46054
46782
|
* refusal that names a flag.
|
|
46055
46783
|
*/
|
|
46056
|
-
function namesNoBoard(projectDir, options) {
|
|
46784
|
+
function namesNoBoard$1(projectDir, options) {
|
|
46057
46785
|
return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
|
|
46058
46786
|
}
|
|
46059
46787
|
/** The workspace a host database — or the board file this project is already bound to — says this project is. */
|
|
@@ -46481,11 +47209,12 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
46481
47209
|
"kanbo is a kanban board shared by people and agents; these tools read and change its cards.",
|
|
46482
47210
|
"",
|
|
46483
47211
|
`- At the start of a session call ${tool("kanbo_prime")} — it returns this board's columns and what each one means.`,
|
|
46484
|
-
`- Take work from ${tool("kanbo_ready")}.`,
|
|
47212
|
+
`- Take work from ${tool("kanbo_ready")}, and claim the card with ${tool("kanbo_card_claim")} before you start: it starts your run and moves the card into work in one step, so no other agent takes it.`,
|
|
46485
47213
|
`- ${MOVE_CARD_RULE} (${tool("kanbo_card_move")})`,
|
|
46486
47214
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
46487
47215
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
46488
47216
|
`- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
|
|
47217
|
+
`- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
|
|
46489
47218
|
`- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
|
|
46490
47219
|
`- ${PERSON_ONLY_RULE}`,
|
|
46491
47220
|
`- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
|
|
@@ -46571,7 +47300,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
46571
47300
|
"## Start of every session",
|
|
46572
47301
|
"1. Run `kanbo prime` (tool: `kanbo_prime`) and read the column descriptions. On this board the description is the contract, not the name.",
|
|
46573
47302
|
"2. Run `kanbo ready` (`kanbo_ready`). If nothing is ready, say so and stop.",
|
|
46574
|
-
"3. Take the first card
|
|
47303
|
+
"3. Take the first card with `kanbo claim --agent orchestrator` (`kanbo_card_claim`): it starts your run and moves the card out of To Do into the board's first work-in-progress column in one step, so no other agent takes it, and it prints the run id you finish later. If another agent got there first, run `kanbo ready` again.",
|
|
46575
47304
|
"",
|
|
46576
47305
|
"## While working",
|
|
46577
47306
|
"- Write a status line (`kanbo card status-line <card> --text \"…\"`, `kanbo_status_line`) at every step and before and after anything that takes more than a minute. One sentence, present tense.",
|
|
@@ -46598,7 +47327,7 @@ const ORCHESTRATOR_GUIDE = [
|
|
|
46598
47327
|
"- Returned: the reason is the newest comment. The card is back in an earlier column; start a new run and address the reason first.",
|
|
46599
47328
|
"",
|
|
46600
47329
|
"## Never",
|
|
46601
|
-
"- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`. They are for a person and will fail in your shell anyway.",
|
|
47330
|
+
"- Never run `kanbo approve`, `kanbo return`, `kanbo sprint close`, `kanbo run clear-session`, or anything that changes the columns: `kanbo columns add`, `kanbo columns rename`, `kanbo columns move`, `kanbo columns remove`, `kanbo columns template`, `kanbo columns add-standard`, `kanbo columns rules`, `kanbo columns flow`. They are for a person and will fail in your shell anyway.",
|
|
46602
47331
|
"- Never delete or edit the board's database directly."
|
|
46603
47332
|
].join("\n");
|
|
46604
47333
|
//#endregion
|
|
@@ -46643,7 +47372,7 @@ function registerInstructionsCommand(program, dependencies = {}) {
|
|
|
46643
47372
|
const found = parseKind(kind);
|
|
46644
47373
|
const body = await readInstructionText(found);
|
|
46645
47374
|
const pointer = found === "short" || found === "global";
|
|
46646
|
-
const text = options.markers ? wrapInstructionBlock(body,
|
|
47375
|
+
const text = options.markers ? wrapInstructionBlock(body, 3, pointer ? void 0 : found) : body;
|
|
46647
47376
|
const hints = process.stderr.isTTY === true;
|
|
46648
47377
|
if (hints) process.stderr.write(`${HEADER}\n\n`);
|
|
46649
47378
|
console.log(text);
|
|
@@ -48076,7 +48805,8 @@ async function createDbTransport(input) {
|
|
|
48076
48805
|
const workspaceId = workspace.id;
|
|
48077
48806
|
const actor = input.actor;
|
|
48078
48807
|
async function readColumns() {
|
|
48079
|
-
|
|
48808
|
+
const rows = await ops.listColumns(workspaceId);
|
|
48809
|
+
return rows.map((row) => projectKanboColumn(row, rows));
|
|
48080
48810
|
}
|
|
48081
48811
|
/** One card with what the run registry says about it, in the shape a tool prints. */
|
|
48082
48812
|
async function project(card, columns) {
|
|
@@ -48126,7 +48856,7 @@ async function createDbTransport(input) {
|
|
|
48126
48856
|
* the waiting and returned ones last said at most two more.
|
|
48127
48857
|
*/
|
|
48128
48858
|
async function projectPage(page, { attend = true } = {}) {
|
|
48129
|
-
const columns = page.columns.map(projectKanboColumn);
|
|
48859
|
+
const columns = page.columns.map((column) => projectKanboColumn(column, page.columns));
|
|
48130
48860
|
const runs = await ops.readBoardProjectionForIssues(page.cards.map((card) => card.id));
|
|
48131
48861
|
const attention = attend ? await ops.readCardAttention(page.cards, runs.map((run) => run.activeRun !== null)) : [];
|
|
48132
48862
|
return page.cards.map((card, index) => projectKanboCard({
|
|
@@ -48193,10 +48923,14 @@ async function createDbTransport(input) {
|
|
|
48193
48923
|
executionMode
|
|
48194
48924
|
}, actor));
|
|
48195
48925
|
},
|
|
48196
|
-
cardUpdate: async ({ card, ...patch }) => {
|
|
48926
|
+
cardUpdate: async ({ card, parent, ...patch }) => {
|
|
48197
48927
|
await session.assertWritable();
|
|
48198
48928
|
const existing = await resolveCard(card);
|
|
48199
|
-
|
|
48929
|
+
const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
|
|
48930
|
+
return await project(await ops.updateCard(existing.id, {
|
|
48931
|
+
...patch,
|
|
48932
|
+
...placement
|
|
48933
|
+
}, actor));
|
|
48200
48934
|
},
|
|
48201
48935
|
cardMove: async ({ card, column }) => {
|
|
48202
48936
|
await session.assertWritable();
|
|
@@ -48254,6 +48988,25 @@ async function createDbTransport(input) {
|
|
|
48254
48988
|
state,
|
|
48255
48989
|
errorText
|
|
48256
48990
|
}, actor));
|
|
48991
|
+
},
|
|
48992
|
+
cardClaim: async ({ card, agent }) => {
|
|
48993
|
+
await session.assertWritable();
|
|
48994
|
+
const named = card === void 0 ? void 0 : (await resolveCard(card)).id;
|
|
48995
|
+
const claimed = await ops.claimCard({
|
|
48996
|
+
workspaceId,
|
|
48997
|
+
issueId: named,
|
|
48998
|
+
run: {
|
|
48999
|
+
agentName: agent ?? actor.id ?? "agent",
|
|
49000
|
+
externalSessionRef: sessionRefFromEnvironment() ?? void 0,
|
|
49001
|
+
launchedByKind: "external"
|
|
49002
|
+
}
|
|
49003
|
+
}, actor);
|
|
49004
|
+
const columns = await readColumns();
|
|
49005
|
+
return {
|
|
49006
|
+
card: await project(claimed.card, columns),
|
|
49007
|
+
run: projectKanboRun(claimed.run),
|
|
49008
|
+
column: columns.find((column) => column.id === claimed.column.id)
|
|
49009
|
+
};
|
|
48257
49010
|
}
|
|
48258
49011
|
};
|
|
48259
49012
|
}
|
|
@@ -48295,7 +49048,8 @@ const TRANSPORT_METHODS = {
|
|
|
48295
49048
|
statusLine: true,
|
|
48296
49049
|
waitApproval: true,
|
|
48297
49050
|
runStart: true,
|
|
48298
|
-
runFinish: true
|
|
49051
|
+
runFinish: true,
|
|
49052
|
+
cardClaim: true
|
|
48299
49053
|
};
|
|
48300
49054
|
async function openMcpBoard(input) {
|
|
48301
49055
|
try {
|
|
@@ -48350,13 +49104,40 @@ function withUpdateNote(board, peek) {
|
|
|
48350
49104
|
}
|
|
48351
49105
|
//#endregion
|
|
48352
49106
|
//#region src/cli/commands/mcp.ts
|
|
49107
|
+
/**
|
|
49108
|
+
* The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
|
|
49109
|
+
* else the agent tool, else `agent` until the client says its name (see
|
|
49110
|
+
* `nameActorAfterClient`).
|
|
49111
|
+
*/
|
|
49112
|
+
function createMcpActor() {
|
|
49113
|
+
const tool = readAgentTool();
|
|
49114
|
+
const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
|
|
49115
|
+
const actor = createCliActor();
|
|
49116
|
+
if (!namedByShell) actor.id = "agent";
|
|
49117
|
+
return {
|
|
49118
|
+
actor,
|
|
49119
|
+
namedByShell
|
|
49120
|
+
};
|
|
49121
|
+
}
|
|
49122
|
+
/**
|
|
49123
|
+
* Files the session's writes under the client's own name (`Test Client` ->
|
|
49124
|
+
* `test-client`) once it has initialized. `actor` is the object the board
|
|
49125
|
+
* holds, so later writes see the new id.
|
|
49126
|
+
*/
|
|
49127
|
+
function nameActorAfterClient(server, actor) {
|
|
49128
|
+
server.server.oninitialized = () => {
|
|
49129
|
+
const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
|
|
49130
|
+
if (id) actor.id = id;
|
|
49131
|
+
};
|
|
49132
|
+
}
|
|
48353
49133
|
function registerMcpCommand(program) {
|
|
48354
49134
|
program.command("mcp").description("Run the board's MCP server for an agent (it starts this itself)").option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").action(async (options) => {
|
|
49135
|
+
const { actor, namedByShell } = createMcpActor();
|
|
48355
49136
|
const board = await openMcpBoard({
|
|
48356
49137
|
dbPath: options.db,
|
|
48357
49138
|
databaseUrl: options.databaseUrl,
|
|
48358
49139
|
workspaceId: options.workspace,
|
|
48359
|
-
actor
|
|
49140
|
+
actor
|
|
48360
49141
|
});
|
|
48361
49142
|
let closing;
|
|
48362
49143
|
const close = () => closing ??= board.close().catch((error) => {
|
|
@@ -48365,6 +49146,7 @@ function registerMcpCommand(program) {
|
|
|
48365
49146
|
const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
|
|
48366
49147
|
const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
|
|
48367
49148
|
server.server.onclose = () => void close();
|
|
49149
|
+
if (!namedByShell) nameActorAfterClient(server, actor);
|
|
48368
49150
|
try {
|
|
48369
49151
|
await server.connect(new StdioServerTransport());
|
|
48370
49152
|
} catch (error) {
|
|
@@ -48873,7 +49655,7 @@ function sendBoardPage(response, pathname, assets) {
|
|
|
48873
49655
|
* answering for a person — approving a card, sending it back or closing a
|
|
48874
49656
|
* sprint — as anyone but one is 403, dropping a pull request link somebody else made is the same 403
|
|
48875
49657
|
* for the same reason, and acting on a run that has already ended is 409,
|
|
48876
|
-
* because the answer would have been yes a moment earlier. An agent putting a
|
|
49658
|
+
* because the answer would have been yes a moment earlier. Starting a run on a card that has one going, and claiming a card nobody can take right now, are 409 for the same reason. An agent putting a
|
|
48877
49659
|
* card in a column whose entry rules it does not meet yet is 409 for the same
|
|
48878
49660
|
* reason turned around: the answer will be yes once the card meets them, and
|
|
48879
49661
|
* `details.unmet` says what is missing. Deleting a column that still holds
|
|
@@ -48891,6 +49673,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
48891
49673
|
message: "Only a person can approve a card",
|
|
48892
49674
|
code: "issue_approval_requires_user"
|
|
48893
49675
|
},
|
|
49676
|
+
board_card_not_ready: {
|
|
49677
|
+
status: 409,
|
|
49678
|
+
message: "That card is not there to take: it is not in To Do, waits for a person, or has a run going"
|
|
49679
|
+
},
|
|
49680
|
+
board_card_run_active: {
|
|
49681
|
+
status: 409,
|
|
49682
|
+
message: "The card already has a run going"
|
|
49683
|
+
},
|
|
48894
49684
|
board_column_name_taken: {
|
|
48895
49685
|
status: 400,
|
|
48896
49686
|
message: "Another column already has that name"
|
|
@@ -48930,6 +49720,14 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
48930
49720
|
status: 400,
|
|
48931
49721
|
message: "Execution mode cannot change once the card has been launched"
|
|
48932
49722
|
},
|
|
49723
|
+
board_flow_rule_invalid: {
|
|
49724
|
+
status: 400,
|
|
49725
|
+
message: "A column accepts cards from other columns of its own board, and holds at least one card"
|
|
49726
|
+
},
|
|
49727
|
+
board_nothing_ready: {
|
|
49728
|
+
status: 409,
|
|
49729
|
+
message: "No card in To Do is ready to take"
|
|
49730
|
+
},
|
|
48933
49731
|
board_pull_request_invalid: {
|
|
48934
49732
|
status: 400,
|
|
48935
49733
|
message: "Not a GitHub pull request: give its URL or owner/repo#number"
|
|
@@ -49009,6 +49807,10 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
49009
49807
|
status: 404,
|
|
49010
49808
|
message: "Issue not found"
|
|
49011
49809
|
},
|
|
49810
|
+
issue_parent_cycle: {
|
|
49811
|
+
status: 400,
|
|
49812
|
+
message: "Issue cannot be put under one of its own sub-issues"
|
|
49813
|
+
},
|
|
49012
49814
|
issue_parent_not_found: {
|
|
49013
49815
|
status: 404,
|
|
49014
49816
|
message: "Parent issue not found"
|
|
@@ -49341,6 +50143,14 @@ const startRunBody = object$1({
|
|
|
49341
50143
|
]).optional(),
|
|
49342
50144
|
externalSessionRef: string().nullable().optional()
|
|
49343
50145
|
});
|
|
50146
|
+
const claimBody = object$1({
|
|
50147
|
+
workspaceId: id,
|
|
50148
|
+
issueId: id.optional(),
|
|
50149
|
+
agentName: id.optional(),
|
|
50150
|
+
branch: string().nullable().optional(),
|
|
50151
|
+
executionMode: executionMode.optional(),
|
|
50152
|
+
externalSessionRef: string().nullable().optional()
|
|
50153
|
+
});
|
|
49344
50154
|
const finishRunBody = object$1({
|
|
49345
50155
|
state: _enum([
|
|
49346
50156
|
"finished",
|
|
@@ -49387,13 +50197,24 @@ async function toWrittenCardView(ops, card) {
|
|
|
49387
50197
|
function toStatusView(status) {
|
|
49388
50198
|
return {
|
|
49389
50199
|
...status,
|
|
49390
|
-
entryRules: readEntryRules(status)
|
|
50200
|
+
entryRules: readEntryRules(status),
|
|
50201
|
+
allowedFrom: readFlowRules(status).allowedFrom
|
|
49391
50202
|
};
|
|
49392
50203
|
}
|
|
49393
|
-
function toCommentView(comment) {
|
|
50204
|
+
function toCommentView(comment, personWriterId = null) {
|
|
50205
|
+
if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
|
|
50206
|
+
...comment,
|
|
50207
|
+
author: {
|
|
50208
|
+
kind: "user",
|
|
50209
|
+
id: comment.authorId ?? "__self__",
|
|
50210
|
+
displayName: "You",
|
|
50211
|
+
avatarUrl: null,
|
|
50212
|
+
label: null
|
|
50213
|
+
}
|
|
50214
|
+
};
|
|
49394
50215
|
return {
|
|
49395
50216
|
...comment,
|
|
49396
|
-
author: resolveActor(commentAuthorReference(comment))
|
|
50217
|
+
author: resolveActor(commentAuthorReference(comment), personWriterId)
|
|
49397
50218
|
};
|
|
49398
50219
|
}
|
|
49399
50220
|
/**
|
|
@@ -49401,23 +50222,34 @@ function toCommentView(comment) {
|
|
|
49401
50222
|
*
|
|
49402
50223
|
* For the kinds that need nobody else's tables: an outside writer is shown
|
|
49403
50224
|
* under the name it gave, the board writing for itself is `kanbo`, and a person
|
|
49404
|
-
* is `You`.
|
|
50225
|
+
* is `You`. So is an outside writer that is the person who started this
|
|
50226
|
+
* server (`personWriterId`): their own writes carry the name their shell files
|
|
50227
|
+
* them under. An agent and a provider target are rows of whatever app launched
|
|
49405
50228
|
* them, which this server cannot read, so they are shown by the id the board
|
|
49406
50229
|
* holds for them.
|
|
49407
50230
|
*/
|
|
49408
|
-
function resolveActor(actor) {
|
|
50231
|
+
function resolveActor(actor, personWriterId) {
|
|
49409
50232
|
if (actor.kind === "system") {
|
|
49410
50233
|
const provenance = {
|
|
49411
50234
|
authorId: actor.id,
|
|
49412
50235
|
sourceChatSessionId: actor.sourceChatSessionId
|
|
49413
50236
|
};
|
|
49414
|
-
if (isOutsideWriter(provenance))
|
|
49415
|
-
|
|
49416
|
-
|
|
49417
|
-
|
|
49418
|
-
|
|
49419
|
-
|
|
49420
|
-
|
|
50237
|
+
if (isOutsideWriter(provenance)) {
|
|
50238
|
+
if (personWriterId !== null && provenance.authorId === personWriterId) return {
|
|
50239
|
+
kind: "user",
|
|
50240
|
+
id: provenance.authorId,
|
|
50241
|
+
displayName: "You",
|
|
50242
|
+
avatarUrl: null,
|
|
50243
|
+
label: null
|
|
50244
|
+
};
|
|
50245
|
+
return {
|
|
50246
|
+
kind: "system",
|
|
50247
|
+
id: provenance.authorId,
|
|
50248
|
+
displayName: provenance.authorId,
|
|
50249
|
+
avatarUrl: null,
|
|
50250
|
+
label: "External"
|
|
50251
|
+
};
|
|
50252
|
+
}
|
|
49421
50253
|
return {
|
|
49422
50254
|
kind: "system",
|
|
49423
50255
|
id: null,
|
|
@@ -49453,13 +50285,13 @@ function resolveActor(actor) {
|
|
|
49453
50285
|
* serves, named by its identity; actors as `resolveActor` reads them; and an
|
|
49454
50286
|
* agent assignee by its id.
|
|
49455
50287
|
*/
|
|
49456
|
-
function createActivityResolver(workspace) {
|
|
50288
|
+
function createActivityResolver(workspace, personWriterId = null) {
|
|
49457
50289
|
return {
|
|
49458
50290
|
workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
|
|
49459
50291
|
id: workspace.id,
|
|
49460
50292
|
name: workspace.name
|
|
49461
50293
|
}]]),
|
|
49462
|
-
resolveActor: async (actor) => resolveActor(actor),
|
|
50294
|
+
resolveActor: async (actor) => resolveActor(actor, personWriterId),
|
|
49463
50295
|
resolveAgentName: async (agentId) => agentId
|
|
49464
50296
|
};
|
|
49465
50297
|
}
|
|
@@ -49480,10 +50312,15 @@ function createActivityResolver(workspace) {
|
|
|
49480
50312
|
const OK = { ok: true };
|
|
49481
50313
|
/** How many cards a search answers with when the caller names no limit. */
|
|
49482
50314
|
const DEFAULT_SEARCH_LIMIT = 20;
|
|
49483
|
-
|
|
50315
|
+
/**
|
|
50316
|
+
* `personWriterId` is the id the person who started the server is filed under;
|
|
50317
|
+
* their writes read as `You`, everyone else's by name.
|
|
50318
|
+
*/
|
|
50319
|
+
function createIssueRoutes(session, options = {}) {
|
|
50320
|
+
const personWriterId = options.personWriterId ?? null;
|
|
49484
50321
|
const { ops, store, workspace } = session;
|
|
49485
50322
|
const workspaceId = workspace.id;
|
|
49486
|
-
const activityResolver = createActivityResolver(workspace);
|
|
50323
|
+
const activityResolver = createActivityResolver(workspace, personWriterId);
|
|
49487
50324
|
/** The served workspace, when that is the one the caller named — or named none. */
|
|
49488
50325
|
function requireWorkspace(named) {
|
|
49489
50326
|
if (named !== void 0 && named !== workspaceId) throw new BoardError("issue_workspace_not_found", { workspaceId: named });
|
|
@@ -49768,6 +50605,15 @@ function createIssueRoutes(session) {
|
|
|
49768
50605
|
return updated;
|
|
49769
50606
|
}
|
|
49770
50607
|
}),
|
|
50608
|
+
route({
|
|
50609
|
+
method: "GET",
|
|
50610
|
+
path: "/issues/workspace",
|
|
50611
|
+
handle: async () => ({
|
|
50612
|
+
id: workspace.id,
|
|
50613
|
+
identifier: workspace.identifier,
|
|
50614
|
+
name: workspace.name
|
|
50615
|
+
})
|
|
50616
|
+
}),
|
|
49771
50617
|
route({
|
|
49772
50618
|
method: "GET",
|
|
49773
50619
|
path: "/issues/:id",
|
|
@@ -49787,6 +50633,31 @@ function createIssueRoutes(session) {
|
|
|
49787
50633
|
}, actors.placement));
|
|
49788
50634
|
}
|
|
49789
50635
|
}),
|
|
50636
|
+
route({
|
|
50637
|
+
method: "POST",
|
|
50638
|
+
path: "/issues/claim",
|
|
50639
|
+
body: claimBody,
|
|
50640
|
+
write: true,
|
|
50641
|
+
handle: async ({ body, actors }) => {
|
|
50642
|
+
const named = requireWorkspace(body.workspaceId);
|
|
50643
|
+
if (body.issueId !== void 0) await requireCard(body.issueId);
|
|
50644
|
+
const claimed = await ops.claimCard({
|
|
50645
|
+
workspaceId: named,
|
|
50646
|
+
issueId: body.issueId,
|
|
50647
|
+
run: {
|
|
50648
|
+
agentName: body.agentName ?? actors.writer.id ?? "agent",
|
|
50649
|
+
branch: body.branch,
|
|
50650
|
+
executionMode: body.executionMode,
|
|
50651
|
+
externalSessionRef: body.externalSessionRef
|
|
50652
|
+
}
|
|
50653
|
+
}, actors.placement);
|
|
50654
|
+
return {
|
|
50655
|
+
card: await toWrittenCardView(ops, claimed.card),
|
|
50656
|
+
run: await toRunView(claimed.run),
|
|
50657
|
+
column: toStatusView(claimed.column)
|
|
50658
|
+
};
|
|
50659
|
+
}
|
|
50660
|
+
}),
|
|
49790
50661
|
route({
|
|
49791
50662
|
method: "PATCH",
|
|
49792
50663
|
path: "/issues/bulk",
|
|
@@ -49921,7 +50792,7 @@ function createIssueRoutes(session) {
|
|
|
49921
50792
|
path: "/issues/:id/comments",
|
|
49922
50793
|
handle: async ({ params }) => {
|
|
49923
50794
|
await requireCard(params.id);
|
|
49924
|
-
return (await ops.listComments(params.id)).map(toCommentView);
|
|
50795
|
+
return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
|
|
49925
50796
|
}
|
|
49926
50797
|
}),
|
|
49927
50798
|
route({
|
|
@@ -49934,7 +50805,7 @@ function createIssueRoutes(session) {
|
|
|
49934
50805
|
return toCommentView(await ops.addComment({
|
|
49935
50806
|
issueId: params.id,
|
|
49936
50807
|
content: body.content
|
|
49937
|
-
}, actors.
|
|
50808
|
+
}, actors.person), personWriterId);
|
|
49938
50809
|
}
|
|
49939
50810
|
}),
|
|
49940
50811
|
route({
|
|
@@ -50063,7 +50934,7 @@ function assertServeBinding(host, token) {
|
|
|
50063
50934
|
}
|
|
50064
50935
|
async function startKanboServer(options) {
|
|
50065
50936
|
assertServeBinding(options.host, options.token);
|
|
50066
|
-
const routes = createIssueRoutes(options.session);
|
|
50937
|
+
const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
|
|
50067
50938
|
const listenHost = stripBrackets(options.host);
|
|
50068
50939
|
let boundPort = 0;
|
|
50069
50940
|
const handle = createRequestHandler(routes, options, () => boundPort);
|
|
@@ -50109,6 +50980,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
|
50109
50980
|
"HEAD",
|
|
50110
50981
|
"OPTIONS"
|
|
50111
50982
|
]);
|
|
50983
|
+
/** What the board page tells the server about itself. */
|
|
50984
|
+
const PRESENCE_BODY = object$1({
|
|
50985
|
+
tabId: string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
|
|
50986
|
+
state: _enum(["open", "closed"])
|
|
50987
|
+
});
|
|
50112
50988
|
function createRequestHandler(routes, options, boundPort) {
|
|
50113
50989
|
const token = options.token || null;
|
|
50114
50990
|
const corsOrigins = new Set(options.corsOrigins ?? []);
|
|
@@ -50169,17 +51045,41 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
50169
51045
|
throw new ServeError(401, "unauthorized", "A valid bearer token is required");
|
|
50170
51046
|
}
|
|
50171
51047
|
if (!SAFE_METHODS.has(request.method ?? "GET") && !isJsonContentType(request.headers["content-type"])) throw new ServeError(415, "unsupported_media_type", "Send the request as content-type: application/json");
|
|
50172
|
-
|
|
50173
|
-
|
|
51048
|
+
if (url.pathname === "/page/presence" && request.method === "POST") {
|
|
51049
|
+
if (!options.presence) {
|
|
51050
|
+
response.writeHead(204).end();
|
|
51051
|
+
return;
|
|
51052
|
+
}
|
|
51053
|
+
const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
|
|
51054
|
+
if (!parsed.success) throw validationError("body", parsed.error);
|
|
51055
|
+
if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
|
|
51056
|
+
else options.presence.closed(parsed.data.tabId);
|
|
51057
|
+
sendJson(response, 200, { ok: true });
|
|
51058
|
+
return;
|
|
51059
|
+
}
|
|
51060
|
+
const projectPath = readProjectPath(url.pathname);
|
|
51061
|
+
if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
|
|
51062
|
+
sendJson(response, 200, options.projects.list());
|
|
51063
|
+
return;
|
|
51064
|
+
}
|
|
51065
|
+
const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
|
|
51066
|
+
if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
|
|
51067
|
+
if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
|
|
50174
51068
|
const body = await readJsonBody(request);
|
|
50175
51069
|
const actors = resolveRequestActors(options.actors, {
|
|
50176
51070
|
actorHeader: request.headers[ACTOR_HEADER],
|
|
50177
51071
|
authenticated: token !== null
|
|
50178
51072
|
});
|
|
50179
51073
|
const result = queue.then(async () => {
|
|
50180
|
-
|
|
50181
|
-
|
|
50182
|
-
|
|
51074
|
+
const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
|
|
51075
|
+
session: options.session,
|
|
51076
|
+
routes
|
|
51077
|
+
};
|
|
51078
|
+
const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
|
|
51079
|
+
if (!found) throw new ServeError(404, "not_found", "Not Found");
|
|
51080
|
+
if (found.route.write) await target.session.assertWritable();
|
|
51081
|
+
return await found.route.run({
|
|
51082
|
+
params: found.params,
|
|
50183
51083
|
query: readQuery(url.searchParams),
|
|
50184
51084
|
body,
|
|
50185
51085
|
actors
|
|
@@ -50205,6 +51105,21 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
50205
51105
|
}
|
|
50206
51106
|
};
|
|
50207
51107
|
}
|
|
51108
|
+
/** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
|
|
51109
|
+
function readProjectPath(pathname) {
|
|
51110
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
51111
|
+
if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
|
|
51112
|
+
let projectId;
|
|
51113
|
+
try {
|
|
51114
|
+
projectId = decodeURIComponent(segments[1]);
|
|
51115
|
+
} catch {
|
|
51116
|
+
return null;
|
|
51117
|
+
}
|
|
51118
|
+
return {
|
|
51119
|
+
projectId,
|
|
51120
|
+
rest: `/${segments.slice(2).join("/")}`
|
|
51121
|
+
};
|
|
51122
|
+
}
|
|
50208
51123
|
function isJsonContentType(value) {
|
|
50209
51124
|
return value?.split(";")[0].trim().toLowerCase() === "application/json";
|
|
50210
51125
|
}
|
|
@@ -50348,6 +51263,146 @@ function readTextOrNull(path) {
|
|
|
50348
51263
|
}
|
|
50349
51264
|
}
|
|
50350
51265
|
//#endregion
|
|
51266
|
+
//#region src/serve/page-presence.ts
|
|
51267
|
+
function createPagePresence(options) {
|
|
51268
|
+
const closedGraceMs = options.closedGraceMs ?? 5e3;
|
|
51269
|
+
const staleMs = options.staleMs ?? 12e4;
|
|
51270
|
+
const tickMs = options.tickMs ?? 1e4;
|
|
51271
|
+
const now = options.now ?? Date.now;
|
|
51272
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
51273
|
+
let finished = false;
|
|
51274
|
+
let graceTimer = null;
|
|
51275
|
+
let lastTick = now();
|
|
51276
|
+
function stop() {
|
|
51277
|
+
finished = true;
|
|
51278
|
+
clearInterval(ticker);
|
|
51279
|
+
if (graceTimer) {
|
|
51280
|
+
clearTimeout(graceTimer);
|
|
51281
|
+
graceTimer = null;
|
|
51282
|
+
}
|
|
51283
|
+
}
|
|
51284
|
+
function idle() {
|
|
51285
|
+
if (finished) return;
|
|
51286
|
+
stop();
|
|
51287
|
+
options.onIdle();
|
|
51288
|
+
}
|
|
51289
|
+
const ticker = setInterval(() => {
|
|
51290
|
+
const current = now();
|
|
51291
|
+
const asleep = current - lastTick > 3 * tickMs;
|
|
51292
|
+
lastTick = current;
|
|
51293
|
+
if (asleep) {
|
|
51294
|
+
for (const id of tabs.keys()) tabs.set(id, current);
|
|
51295
|
+
return;
|
|
51296
|
+
}
|
|
51297
|
+
if (tabs.size === 0) return;
|
|
51298
|
+
for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
|
|
51299
|
+
if (tabs.size === 0) idle();
|
|
51300
|
+
}, tickMs);
|
|
51301
|
+
ticker.unref();
|
|
51302
|
+
return {
|
|
51303
|
+
seen: (tabId) => {
|
|
51304
|
+
if (!finished) tabs.set(tabId, now());
|
|
51305
|
+
},
|
|
51306
|
+
closed: (tabId) => {
|
|
51307
|
+
if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
|
|
51308
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
51309
|
+
graceTimer = setTimeout(() => {
|
|
51310
|
+
graceTimer = null;
|
|
51311
|
+
if (tabs.size === 0) idle();
|
|
51312
|
+
}, closedGraceMs);
|
|
51313
|
+
graceTimer.unref();
|
|
51314
|
+
},
|
|
51315
|
+
stop
|
|
51316
|
+
};
|
|
51317
|
+
}
|
|
51318
|
+
//#endregion
|
|
51319
|
+
//#region src/serve/projects.ts
|
|
51320
|
+
function createServeProjects(input) {
|
|
51321
|
+
const routesOptions = { personWriterId: input.personWriterId ?? null };
|
|
51322
|
+
const opened = /* @__PURE__ */ new Map();
|
|
51323
|
+
let defaultOpened = null;
|
|
51324
|
+
/** Every project that can be offered right now, default first, with the folder each lives in. */
|
|
51325
|
+
function entries() {
|
|
51326
|
+
const defaultDir = input.defaultProject?.dir ?? null;
|
|
51327
|
+
const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
|
|
51328
|
+
const found = [];
|
|
51329
|
+
for (const dir of dirs) {
|
|
51330
|
+
const binding = input.readBinding(dir);
|
|
51331
|
+
if (binding === null && dir !== defaultDir) continue;
|
|
51332
|
+
found.push({
|
|
51333
|
+
view: viewOf(dir, binding, dir === defaultDir),
|
|
51334
|
+
dir
|
|
51335
|
+
});
|
|
51336
|
+
}
|
|
51337
|
+
return found;
|
|
51338
|
+
}
|
|
51339
|
+
return {
|
|
51340
|
+
list: () => entries().map((entry) => entry.view),
|
|
51341
|
+
resolve: async (projectId) => {
|
|
51342
|
+
const entry = entries().find((candidate) => candidate.view.id === projectId);
|
|
51343
|
+
if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
|
|
51344
|
+
if (entry.view.isDefault && input.defaultProject) {
|
|
51345
|
+
defaultOpened ??= {
|
|
51346
|
+
session: input.defaultProject.session,
|
|
51347
|
+
routes: createIssueRoutes(input.defaultProject.session, routesOptions)
|
|
51348
|
+
};
|
|
51349
|
+
return defaultOpened;
|
|
51350
|
+
}
|
|
51351
|
+
const cached = opened.get(projectId);
|
|
51352
|
+
if (cached) return cached;
|
|
51353
|
+
let session;
|
|
51354
|
+
try {
|
|
51355
|
+
session = await input.openSession(entry.dir);
|
|
51356
|
+
} catch (error) {
|
|
51357
|
+
throw new ServeError(503, "project_unavailable", describeFailure(error).message);
|
|
51358
|
+
}
|
|
51359
|
+
const project = {
|
|
51360
|
+
session,
|
|
51361
|
+
routes: createIssueRoutes(session, routesOptions)
|
|
51362
|
+
};
|
|
51363
|
+
opened.set(projectId, project);
|
|
51364
|
+
return project;
|
|
51365
|
+
},
|
|
51366
|
+
close: async () => {
|
|
51367
|
+
const sessions = [...opened.values()].map((project) => project.session);
|
|
51368
|
+
opened.clear();
|
|
51369
|
+
await Promise.allSettled(sessions.map(async (session) => await session.close()));
|
|
51370
|
+
}
|
|
51371
|
+
};
|
|
51372
|
+
}
|
|
51373
|
+
function projectId(dir) {
|
|
51374
|
+
return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
|
|
51375
|
+
}
|
|
51376
|
+
function viewOf(dir, binding, isDefault) {
|
|
51377
|
+
return {
|
|
51378
|
+
id: projectId(dir),
|
|
51379
|
+
name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
|
|
51380
|
+
folder: tildify(dir),
|
|
51381
|
+
storage: storageOf(dir, binding),
|
|
51382
|
+
isDefault
|
|
51383
|
+
};
|
|
51384
|
+
}
|
|
51385
|
+
/** Where the board lives. The connection string is read here to name its host, and goes no further. */
|
|
51386
|
+
function storageOf(dir, binding) {
|
|
51387
|
+
if (binding?.databaseUrl) return {
|
|
51388
|
+
kind: "postgres",
|
|
51389
|
+
where: hostOf(maskDatabaseUrl(binding.databaseUrl))
|
|
51390
|
+
};
|
|
51391
|
+
return {
|
|
51392
|
+
kind: "file",
|
|
51393
|
+
path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
|
|
51394
|
+
};
|
|
51395
|
+
}
|
|
51396
|
+
/** The host of an already masked connection string; a string that is not one names no host. */
|
|
51397
|
+
function hostOf(maskedUrl) {
|
|
51398
|
+
try {
|
|
51399
|
+
const { host } = new URL(maskedUrl);
|
|
51400
|
+
return host && !host.includes("*") ? host : "a Postgres database";
|
|
51401
|
+
} catch {
|
|
51402
|
+
return "a Postgres database";
|
|
51403
|
+
}
|
|
51404
|
+
}
|
|
51405
|
+
//#endregion
|
|
50351
51406
|
//#region src/cli/commands/serve.ts
|
|
50352
51407
|
function registerServeCommand(program) {
|
|
50353
51408
|
program.command("serve").description("Open the board in your browser").option("--port <port>", "port to listen on", String(DEFAULT_SERVE_PORT)).option("--host <host>", "address to listen on; anything but this computer needs a token", DEFAULT_SERVE_HOST).option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").option("--token <token>", "a password every request must carry (or set KANBO_SERVE_TOKEN); made up for you when left out").option("--cors-origin <origin>", "let pages from this web address call the board; repeat for more", collect, []).option("--no-open", "print the board page link without opening it in the browser").action(async (options) => {
|
|
@@ -50355,7 +51410,7 @@ function registerServeCommand(program) {
|
|
|
50355
51410
|
status: (line) => console.error(line),
|
|
50356
51411
|
page: (line) => console.log(line)
|
|
50357
51412
|
});
|
|
50358
|
-
await
|
|
51413
|
+
if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
|
|
50359
51414
|
try {
|
|
50360
51415
|
await running.close();
|
|
50361
51416
|
process.exit(0);
|
|
@@ -50367,26 +51422,46 @@ function registerServeCommand(program) {
|
|
|
50367
51422
|
}
|
|
50368
51423
|
/**
|
|
50369
51424
|
* Open the board and serve it — everything `kanbo serve` does but wait. The
|
|
50370
|
-
* command waits for Ctrl-C
|
|
50371
|
-
*
|
|
51425
|
+
* command waits for Ctrl-C (or the last board tab closing) and ends the
|
|
51426
|
+
* process; the home menu waits the same and comes back to its menu.
|
|
50372
51427
|
*/
|
|
50373
51428
|
async function startServe(options, output) {
|
|
50374
51429
|
const port = parsePort(options.port);
|
|
50375
51430
|
const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
|
|
51431
|
+
const here = findBinding(process.cwd());
|
|
51432
|
+
const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
|
|
51433
|
+
const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
|
|
50376
51434
|
const session = await openBoardSession({
|
|
50377
51435
|
db: options.db,
|
|
50378
51436
|
databaseUrl: options.databaseUrl,
|
|
50379
51437
|
workspace: options.workspace
|
|
50380
|
-
}, "read");
|
|
51438
|
+
}, "read", mostRecent?.path);
|
|
50381
51439
|
const agentShell = isAgentShell();
|
|
50382
51440
|
const actors = {
|
|
50383
|
-
writer:
|
|
51441
|
+
writer: createServeWriter(),
|
|
50384
51442
|
person: agentShell ? null : createPersonActor()
|
|
50385
51443
|
};
|
|
51444
|
+
const projects = createServeProjects({
|
|
51445
|
+
defaultProject: defaultDir === null ? null : {
|
|
51446
|
+
dir: defaultDir,
|
|
51447
|
+
session
|
|
51448
|
+
},
|
|
51449
|
+
readRegistry: () => readProjectRegistry(),
|
|
51450
|
+
readBinding: (dir) => readProjectBinding(dir),
|
|
51451
|
+
openSession: async (dir) => await openBoardSession({}, "read", dir),
|
|
51452
|
+
personWriterId: actors.writer.id
|
|
51453
|
+
});
|
|
51454
|
+
const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
|
|
51455
|
+
let presence;
|
|
51456
|
+
const idle = new Promise((resolve) => {
|
|
51457
|
+
presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
|
|
51458
|
+
});
|
|
50386
51459
|
let server;
|
|
50387
51460
|
try {
|
|
50388
51461
|
server = await startKanboServer({
|
|
50389
51462
|
session,
|
|
51463
|
+
projects,
|
|
51464
|
+
presence,
|
|
50390
51465
|
actors,
|
|
50391
51466
|
host: options.host,
|
|
50392
51467
|
port,
|
|
@@ -50394,10 +51469,12 @@ async function startServe(options, output) {
|
|
|
50394
51469
|
corsOrigins: options.corsOrigin
|
|
50395
51470
|
});
|
|
50396
51471
|
} catch (error) {
|
|
51472
|
+
presence?.stop();
|
|
50397
51473
|
await session.close();
|
|
50398
51474
|
throw error;
|
|
50399
51475
|
}
|
|
50400
|
-
|
|
51476
|
+
const projectCount = projects.list().length;
|
|
51477
|
+
output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
|
|
50401
51478
|
output.status(`kanbo serve: ${describePersonRights(actors)}`);
|
|
50402
51479
|
presentServePage({
|
|
50403
51480
|
url: server.url,
|
|
@@ -50411,15 +51488,25 @@ async function startServe(options, output) {
|
|
|
50411
51488
|
});
|
|
50412
51489
|
return {
|
|
50413
51490
|
link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
|
|
51491
|
+
idle,
|
|
50414
51492
|
close: async () => {
|
|
51493
|
+
presence?.stop();
|
|
50415
51494
|
try {
|
|
50416
51495
|
await server.close();
|
|
50417
51496
|
} finally {
|
|
50418
|
-
|
|
51497
|
+
try {
|
|
51498
|
+
await projects.close();
|
|
51499
|
+
} finally {
|
|
51500
|
+
await session.close();
|
|
51501
|
+
}
|
|
50419
51502
|
}
|
|
50420
51503
|
}
|
|
50421
51504
|
};
|
|
50422
51505
|
}
|
|
51506
|
+
/** Whether neither a flag nor the environment says which board to open. */
|
|
51507
|
+
function namesNoBoard(options) {
|
|
51508
|
+
return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
|
|
51509
|
+
}
|
|
50423
51510
|
/** What `kanbo serve` with no options is told — the home menu serves the board this way. */
|
|
50424
51511
|
const DEFAULT_SERVE_OPTIONS = {
|
|
50425
51512
|
port: String(DEFAULT_SERVE_PORT),
|
|
@@ -50427,16 +51514,21 @@ const DEFAULT_SERVE_OPTIONS = {
|
|
|
50427
51514
|
corsOrigin: [],
|
|
50428
51515
|
open: true
|
|
50429
51516
|
};
|
|
50430
|
-
/**
|
|
50431
|
-
|
|
51517
|
+
/**
|
|
51518
|
+
* Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
|
|
51519
|
+
* stops listening for both signals whichever it was.
|
|
51520
|
+
*/
|
|
51521
|
+
function waitForStop(idle) {
|
|
50432
51522
|
return new Promise((resolve) => {
|
|
50433
|
-
const
|
|
50434
|
-
process.off("SIGINT",
|
|
50435
|
-
process.off("SIGTERM",
|
|
50436
|
-
resolve();
|
|
51523
|
+
const finish = (why) => {
|
|
51524
|
+
process.off("SIGINT", onSignal);
|
|
51525
|
+
process.off("SIGTERM", onSignal);
|
|
51526
|
+
resolve(why);
|
|
50437
51527
|
};
|
|
50438
|
-
|
|
50439
|
-
process.on("
|
|
51528
|
+
const onSignal = () => finish("interrupt");
|
|
51529
|
+
process.on("SIGINT", onSignal);
|
|
51530
|
+
process.on("SIGTERM", onSignal);
|
|
51531
|
+
idle.then(() => finish("idle"));
|
|
50440
51532
|
});
|
|
50441
51533
|
}
|
|
50442
51534
|
function collect(value, previous) {
|
|
@@ -50707,6 +51799,7 @@ function registerKanboCommands(program, context = {}) {
|
|
|
50707
51799
|
registerServeCommand(program);
|
|
50708
51800
|
program.commandsGroup("For agents and integrations:");
|
|
50709
51801
|
registerMcpCommand(program);
|
|
51802
|
+
registerClaimCommand(program);
|
|
50710
51803
|
registerRunCommands(program);
|
|
50711
51804
|
registerCapabilitiesCommand(program);
|
|
50712
51805
|
program.commandsGroup("Shared Postgres boards:");
|
|
@@ -50720,7 +51813,7 @@ const SERVE_FROM_MENU = {
|
|
|
50720
51813
|
status: (line) => console.error(line),
|
|
50721
51814
|
page: () => {}
|
|
50722
51815
|
}),
|
|
50723
|
-
waitForStop:
|
|
51816
|
+
waitForStop: (running) => waitForStop(running.idle)
|
|
50724
51817
|
};
|
|
50725
51818
|
/** The `--check` instruction cells that mean a kanbo section is there and readable. */
|
|
50726
51819
|
const INSTRUCTION_BLOCK_PRESENT = /* @__PURE__ */ new Set([
|
|
@@ -50882,7 +51975,7 @@ async function serveUntilStopped(context) {
|
|
|
50882
51975
|
const running = await start();
|
|
50883
51976
|
try {
|
|
50884
51977
|
say(context.ui, `Board open at ${running.link} — press Ctrl-C to stop and return to the menu`);
|
|
50885
|
-
await waitForStop();
|
|
51978
|
+
await waitForStop(running);
|
|
50886
51979
|
} finally {
|
|
50887
51980
|
await running.close();
|
|
50888
51981
|
}
|