kanbo-cli 0.3.0 → 0.4.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 +29 -0
- package/README.md +2 -0
- package/dist/cli-main.cjs +628 -146
- package/dist/lib/index.cjs +1 -1
- package/dist/lib/index.d.cts +1 -1
- package/dist/lib/index.d.mts +1 -1
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/{manifest-QPoQl8tn.cjs → manifest-DcIMYvWk.cjs} +673 -191
- package/dist/lib/{manifest-v2e9x_C8.mjs → manifest-Dmo6IlWk.mjs} +674 -192
- package/dist/lib/mcp/index.cjs +12 -3
- package/dist/lib/mcp/index.d.cts +4 -0
- package/dist/lib/mcp/index.d.mts +4 -0
- package/dist/lib/mcp/index.mjs +12 -3
- package/dist/page/board.css +1622 -327
- package/dist/page/board.js +2 -2
- package/package.json +1 -1
|
@@ -52,22 +52,26 @@ const AGENT_SHELL_MARKERS = [
|
|
|
52
52
|
{
|
|
53
53
|
variable: "CLAUDECODE",
|
|
54
54
|
matches: (value) => value === "1",
|
|
55
|
-
label: "CLAUDECODE=1"
|
|
55
|
+
label: "CLAUDECODE=1",
|
|
56
|
+
tool: "claude"
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
variable: "GEMINI_CLI",
|
|
59
60
|
matches: (value) => value === "1",
|
|
60
|
-
label: "GEMINI_CLI=1"
|
|
61
|
+
label: "GEMINI_CLI=1",
|
|
62
|
+
tool: "gemini"
|
|
61
63
|
},
|
|
62
64
|
{
|
|
63
65
|
variable: "CURSOR_AGENT",
|
|
64
66
|
matches: (value) => value !== "",
|
|
65
|
-
label: "CURSOR_AGENT"
|
|
67
|
+
label: "CURSOR_AGENT",
|
|
68
|
+
tool: "cursor"
|
|
66
69
|
},
|
|
67
70
|
{
|
|
68
71
|
variable: "CODEX_THREAD_ID",
|
|
69
72
|
matches: (value) => value !== "",
|
|
70
|
-
label: "CODEX_THREAD_ID"
|
|
73
|
+
label: "CODEX_THREAD_ID",
|
|
74
|
+
tool: "codex"
|
|
71
75
|
}
|
|
72
76
|
];
|
|
73
77
|
/**
|
|
@@ -85,6 +89,15 @@ function readAgentShellMarker(env = process.env) {
|
|
|
85
89
|
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
|
|
86
90
|
}
|
|
87
91
|
/**
|
|
92
|
+
* The name of the agent tool this shell belongs to (`claude`, `gemini`,
|
|
93
|
+
* `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
|
|
94
|
+
* `null` for a person's shell. Same precedence as `readAgentShellMarker`.
|
|
95
|
+
*/
|
|
96
|
+
function readAgentTool(env = process.env) {
|
|
97
|
+
if (readAgentShellMarker(env) === null) return null;
|
|
98
|
+
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
88
101
|
* Every exit code this tool promises, and what it means — the capabilities
|
|
89
102
|
* manifest's `exitCodes` reads this rather than naming the codes again. `0` and
|
|
90
103
|
* `1` are not declared above: they need no name of their own, success and "I
|
|
@@ -280,11 +293,15 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
280
293
|
"run clear-session": describeRunSessionRefRefusal,
|
|
281
294
|
"run attach-session --replace": describeRunSessionRefRefusal
|
|
282
295
|
};
|
|
283
|
-
/**
|
|
296
|
+
/**
|
|
297
|
+
* The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
|
|
298
|
+
* else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
|
|
299
|
+
* operating system's user.
|
|
300
|
+
*/
|
|
284
301
|
function createCliActor() {
|
|
285
302
|
return {
|
|
286
303
|
kind: "external",
|
|
287
|
-
id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
|
|
304
|
+
id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
|
|
288
305
|
name: hostname()
|
|
289
306
|
};
|
|
290
307
|
}
|
|
@@ -322,6 +339,14 @@ function createPlacementActor() {
|
|
|
322
339
|
placesForPerson: true
|
|
323
340
|
};
|
|
324
341
|
}
|
|
342
|
+
/** 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. */
|
|
343
|
+
function createServeWriter() {
|
|
344
|
+
return {
|
|
345
|
+
kind: "external",
|
|
346
|
+
id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
|
|
347
|
+
name: hostname()
|
|
348
|
+
};
|
|
349
|
+
}
|
|
325
350
|
/**
|
|
326
351
|
* The person who owns this shell, as the board files a person. Only for a
|
|
327
352
|
* caller that has already asked `isAgentShell` — `requireHumanActor` for a
|
|
@@ -2811,12 +2836,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
|
|
|
2811
2836
|
issueId,
|
|
2812
2837
|
parentIssueId
|
|
2813
2838
|
});
|
|
2814
|
-
|
|
2839
|
+
const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
|
|
2840
|
+
if (!parent) throw new BoardError("issue_parent_not_found", {
|
|
2815
2841
|
workspaceId,
|
|
2816
2842
|
parentIssueId
|
|
2817
2843
|
});
|
|
2844
|
+
if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new BoardError("issue_parent_cycle", {
|
|
2845
|
+
issueId,
|
|
2846
|
+
parentIssueId
|
|
2847
|
+
});
|
|
2818
2848
|
return parentIssueId;
|
|
2819
2849
|
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Is the card somewhere under `ancestorId` — its parent, its parent's parent,
|
|
2852
|
+
* and so on up? A card put under one of its own sub-cards would make a loop no
|
|
2853
|
+
* reader of the board can walk to the top of. The cards already seen end the
|
|
2854
|
+
* walk, so a loop the data holds from before cannot hang it.
|
|
2855
|
+
*/
|
|
2856
|
+
async function isUnder(store, card, ancestorId) {
|
|
2857
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2858
|
+
let id = card.parentIssueId;
|
|
2859
|
+
while (id !== null && !seen.has(id)) {
|
|
2860
|
+
if (id === ancestorId) return true;
|
|
2861
|
+
seen.add(id);
|
|
2862
|
+
id = (await store.issues.findById(id))?.parentIssueId ?? null;
|
|
2863
|
+
}
|
|
2864
|
+
return false;
|
|
2865
|
+
}
|
|
2820
2866
|
/** Is the card's own number already taken by another card in the target workspace? */
|
|
2821
2867
|
async function numberTakenByAnother(store, workspaceId, card) {
|
|
2822
2868
|
const holder = await store.issues.findByNumber(workspaceId, card.number);
|
|
@@ -3685,62 +3731,6 @@ function createBoardOps(store) {
|
|
|
3685
3731
|
};
|
|
3686
3732
|
}
|
|
3687
3733
|
//#endregion
|
|
3688
|
-
//#region src/domain/database-url.ts
|
|
3689
|
-
/**
|
|
3690
|
-
* A board's connection string, as it may be said out loud.
|
|
3691
|
-
*
|
|
3692
|
-
* Everything a person needs to recognise which board they are talking to is
|
|
3693
|
-
* the host and the database name. The password is the one part that must never
|
|
3694
|
-
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
3695
|
-
* the only part: a connection string carries options in its query, and a
|
|
3696
|
-
* `?password=…` there is as much a secret as the one in front of the host.
|
|
3697
|
-
*
|
|
3698
|
-
* It is a fact about the value rather than about the command line, which is why
|
|
3699
|
-
* it lives here: the MCP tools hand back messages they did not write either,
|
|
3700
|
-
* and they must not be made to carry the board tool's own runtime to say them
|
|
3701
|
-
* safely.
|
|
3702
|
-
*/
|
|
3703
|
-
/** What a connection string is called when there is nothing safe to print of it. */
|
|
3704
|
-
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
3705
|
-
/** What is printed in place of a password, and of a query that may hide one. */
|
|
3706
|
-
const MASK = "***";
|
|
3707
|
-
/**
|
|
3708
|
-
* A connection string inside a longer line. Anything but whitespace and the
|
|
3709
|
-
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
3710
|
-
* every character a URL is, so the match has to be greedy to be safe.
|
|
3711
|
-
*/
|
|
3712
|
-
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
3713
|
-
/**
|
|
3714
|
-
* The connection string with everything secret taken out of it.
|
|
3715
|
-
*
|
|
3716
|
-
* A string this cannot take apart is not examined to find out why — it is
|
|
3717
|
-
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
3718
|
-
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
3719
|
-
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
3720
|
-
* back would say the secret out loud.
|
|
3721
|
-
*/
|
|
3722
|
-
function maskDatabaseUrl(url) {
|
|
3723
|
-
try {
|
|
3724
|
-
const parsed = new URL(url);
|
|
3725
|
-
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
3726
|
-
if (parsed.password) parsed.password = MASK;
|
|
3727
|
-
if (parsed.search) parsed.search = MASK;
|
|
3728
|
-
return parsed.toString();
|
|
3729
|
-
} catch {
|
|
3730
|
-
return MASKED_UNREADABLE_URL;
|
|
3731
|
-
}
|
|
3732
|
-
}
|
|
3733
|
-
/**
|
|
3734
|
-
* Every connection string inside a line of text, masked.
|
|
3735
|
-
*
|
|
3736
|
-
* A message this package did not write — a driver's, a migrator's — may carry
|
|
3737
|
-
* the whole connection string in the middle of a sentence, and it reaches the
|
|
3738
|
-
* same terminal, or the same agent, as everything else.
|
|
3739
|
-
*/
|
|
3740
|
-
function maskDatabaseUrls(text) {
|
|
3741
|
-
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
3742
|
-
}
|
|
3743
|
-
//#endregion
|
|
3744
3734
|
//#region src/cli/setup/fs-retry.ts
|
|
3745
3735
|
/**
|
|
3746
3736
|
* A file operation tried again while something else holds the file.
|
|
@@ -4020,6 +4010,62 @@ function ignoreBoardFile(projectDir, absoluteDbPath) {
|
|
|
4020
4010
|
writeFileSync(path, kept ? `${kept}\n${missing.join("\n")}\n` : `${missing.join("\n")}\n`);
|
|
4021
4011
|
}
|
|
4022
4012
|
//#endregion
|
|
4013
|
+
//#region src/domain/database-url.ts
|
|
4014
|
+
/**
|
|
4015
|
+
* A board's connection string, as it may be said out loud.
|
|
4016
|
+
*
|
|
4017
|
+
* Everything a person needs to recognise which board they are talking to is
|
|
4018
|
+
* the host and the database name. The password is the one part that must never
|
|
4019
|
+
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
4020
|
+
* the only part: a connection string carries options in its query, and a
|
|
4021
|
+
* `?password=…` there is as much a secret as the one in front of the host.
|
|
4022
|
+
*
|
|
4023
|
+
* It is a fact about the value rather than about the command line, which is why
|
|
4024
|
+
* it lives here: the MCP tools hand back messages they did not write either,
|
|
4025
|
+
* and they must not be made to carry the board tool's own runtime to say them
|
|
4026
|
+
* safely.
|
|
4027
|
+
*/
|
|
4028
|
+
/** What a connection string is called when there is nothing safe to print of it. */
|
|
4029
|
+
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
4030
|
+
/** What is printed in place of a password, and of a query that may hide one. */
|
|
4031
|
+
const MASK = "***";
|
|
4032
|
+
/**
|
|
4033
|
+
* A connection string inside a longer line. Anything but whitespace and the
|
|
4034
|
+
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
4035
|
+
* every character a URL is, so the match has to be greedy to be safe.
|
|
4036
|
+
*/
|
|
4037
|
+
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
4038
|
+
/**
|
|
4039
|
+
* The connection string with everything secret taken out of it.
|
|
4040
|
+
*
|
|
4041
|
+
* A string this cannot take apart is not examined to find out why — it is
|
|
4042
|
+
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
4043
|
+
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
4044
|
+
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
4045
|
+
* back would say the secret out loud.
|
|
4046
|
+
*/
|
|
4047
|
+
function maskDatabaseUrl(url) {
|
|
4048
|
+
try {
|
|
4049
|
+
const parsed = new URL(url);
|
|
4050
|
+
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
4051
|
+
if (parsed.password) parsed.password = MASK;
|
|
4052
|
+
if (parsed.search) parsed.search = MASK;
|
|
4053
|
+
return parsed.toString();
|
|
4054
|
+
} catch {
|
|
4055
|
+
return MASKED_UNREADABLE_URL;
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
/**
|
|
4059
|
+
* Every connection string inside a line of text, masked.
|
|
4060
|
+
*
|
|
4061
|
+
* A message this package did not write — a driver's, a migrator's — may carry
|
|
4062
|
+
* the whole connection string in the middle of a sentence, and it reaches the
|
|
4063
|
+
* same terminal, or the same agent, as everything else.
|
|
4064
|
+
*/
|
|
4065
|
+
function maskDatabaseUrls(text) {
|
|
4066
|
+
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
4067
|
+
}
|
|
4068
|
+
//#endregion
|
|
4023
4069
|
//#region src/cli/db-target.ts
|
|
4024
4070
|
/**
|
|
4025
4071
|
* Which board the command works on.
|
|
@@ -4268,6 +4314,111 @@ async function openWithPostgresJs(url) {
|
|
|
4268
4314
|
};
|
|
4269
4315
|
}
|
|
4270
4316
|
//#endregion
|
|
4317
|
+
//#region src/cli/project-registry.ts
|
|
4318
|
+
/**
|
|
4319
|
+
* Which project folders kanbo has opened a board in.
|
|
4320
|
+
*
|
|
4321
|
+
* Every project keeps its board in its own folder, so nothing else knows which
|
|
4322
|
+
* other projects exist. This list, in the user's home, is what lets one board
|
|
4323
|
+
* page offer them all. It holds a folder and a time — never a connection
|
|
4324
|
+
* string, never board data — and deleting it is safe: it fills again as the
|
|
4325
|
+
* projects are used.
|
|
4326
|
+
*/
|
|
4327
|
+
/** The file's name inside kanbo's per-user folder. */
|
|
4328
|
+
const REGISTRY_FILE_NAME = "projects.json";
|
|
4329
|
+
/** The file is private to the user, as a binding is. */
|
|
4330
|
+
const REGISTRY_FILE_MODE = 384;
|
|
4331
|
+
/** The folder holding it is private to the user too. */
|
|
4332
|
+
const REGISTRY_DIRECTORY_MODE = 448;
|
|
4333
|
+
/**
|
|
4334
|
+
* A project noted within this many seconds is not noted again, so an ordinary
|
|
4335
|
+
* command does not rewrite the file: a day is fresh enough for "recently used".
|
|
4336
|
+
*/
|
|
4337
|
+
const RECENTLY_NOTED_SECONDS = 86400;
|
|
4338
|
+
const RegisteredProjectSchema = z.object({
|
|
4339
|
+
path: z.string().min(1),
|
|
4340
|
+
lastSeenAt: z.number().int().nonnegative()
|
|
4341
|
+
});
|
|
4342
|
+
const RegistrySchema = z.object({
|
|
4343
|
+
schemaVersion: z.literal(1),
|
|
4344
|
+
projects: z.array(RegisteredProjectSchema)
|
|
4345
|
+
});
|
|
4346
|
+
/** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
|
|
4347
|
+
function projectRegistryPath(env = process.env) {
|
|
4348
|
+
const home = env.KANBO_HOME?.trim();
|
|
4349
|
+
return join(home || join(homedir(), ".kanbo"), REGISTRY_FILE_NAME);
|
|
4350
|
+
}
|
|
4351
|
+
/** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
|
|
4352
|
+
function readProjectRegistry(path = projectRegistryPath()) {
|
|
4353
|
+
return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
4354
|
+
}
|
|
4355
|
+
/**
|
|
4356
|
+
* Note that `projectDir` was just used. Writes only when the folder is new or
|
|
4357
|
+
* was last noted more than a day ago, so ordinary commands do not rewrite the file.
|
|
4358
|
+
* Drops entries whose `.kanbo/binding.json` is gone.
|
|
4359
|
+
*/
|
|
4360
|
+
function rememberProject(projectDir, options = {}) {
|
|
4361
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
4362
|
+
const path = options.path ?? projectRegistryPath();
|
|
4363
|
+
const canonical = canonicalPath$1(projectDir);
|
|
4364
|
+
const entries = readEntries(path);
|
|
4365
|
+
const known = entries.find((entry) => entry.path === canonical);
|
|
4366
|
+
if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
|
|
4367
|
+
const kept = entries.filter((entry) => entry.path !== canonical && existsSync(join(entry.path, BINDING_FILE_PATH)));
|
|
4368
|
+
kept.push({
|
|
4369
|
+
path: canonical,
|
|
4370
|
+
lastSeenAt: now
|
|
4371
|
+
});
|
|
4372
|
+
writeEntries(path, kept);
|
|
4373
|
+
}
|
|
4374
|
+
/**
|
|
4375
|
+
* `rememberProject`, for the places that must not fail because of it: a home
|
|
4376
|
+
* directory that cannot be written is not a reason for `kanbo card list` to fail.
|
|
4377
|
+
* With `KANBO_DEBUG=1` the reason is printed on stderr.
|
|
4378
|
+
*/
|
|
4379
|
+
function rememberProjectQuietly(projectDir) {
|
|
4380
|
+
const path = projectRegistryPath();
|
|
4381
|
+
try {
|
|
4382
|
+
rememberProject(projectDir, { path });
|
|
4383
|
+
} catch (error) {
|
|
4384
|
+
if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
function canonicalPath$1(projectDir) {
|
|
4388
|
+
const absolute = resolve(projectDir);
|
|
4389
|
+
try {
|
|
4390
|
+
return realpathSync(absolute);
|
|
4391
|
+
} catch {
|
|
4392
|
+
return absolute;
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
function readEntries(path) {
|
|
4396
|
+
try {
|
|
4397
|
+
const parsed = RegistrySchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
4398
|
+
return parsed.success ? parsed.data.projects : [];
|
|
4399
|
+
} catch {
|
|
4400
|
+
return [];
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
function writeEntries(path, projects) {
|
|
4404
|
+
mkdirSync(dirname(path), {
|
|
4405
|
+
recursive: true,
|
|
4406
|
+
mode: REGISTRY_DIRECTORY_MODE
|
|
4407
|
+
});
|
|
4408
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
4409
|
+
try {
|
|
4410
|
+
writeFileSync(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
|
|
4411
|
+
schemaVersion: 1,
|
|
4412
|
+
projects
|
|
4413
|
+
}), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
|
|
4414
|
+
chmodSync(temporaryPath, REGISTRY_FILE_MODE);
|
|
4415
|
+
renameWithRetry(temporaryPath, path);
|
|
4416
|
+
} catch (error) {
|
|
4417
|
+
rmSync(temporaryPath, { force: true });
|
|
4418
|
+
throw error;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
//#endregion
|
|
4271
4422
|
//#region src/cli/schema-guard.ts
|
|
4272
4423
|
const SCHEMA_OUTDATED_MESSAGE = "The app's database is older than this kanbo. Let the app it belongs to update it first.";
|
|
4273
4424
|
/** The same, for a board file this package created and migrates itself. */
|
|
@@ -4513,15 +4664,17 @@ function withBoardOptions(command) {
|
|
|
4513
4664
|
* as a client is talking to it. A failure on the way out closes the board first
|
|
4514
4665
|
* — a caller that never received a session has nothing to close.
|
|
4515
4666
|
*/
|
|
4516
|
-
async function openBoardSession(options, mode) {
|
|
4517
|
-
const cwd = process.cwd();
|
|
4667
|
+
async function openBoardSession(options, mode, cwd = process.cwd()) {
|
|
4518
4668
|
const target = resolveDbTarget({
|
|
4519
4669
|
explicitPath: options.db,
|
|
4520
4670
|
explicitUrl: options.databaseUrl,
|
|
4521
4671
|
asAgent: options.asAgent,
|
|
4522
4672
|
cwd
|
|
4523
4673
|
});
|
|
4524
|
-
|
|
4674
|
+
const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
|
|
4675
|
+
const found = findBinding(cwd);
|
|
4676
|
+
if (found) rememberProjectQuietly(found.projectDir);
|
|
4677
|
+
return session;
|
|
4525
4678
|
}
|
|
4526
4679
|
async function openFileSession(target, options, mode, cwd) {
|
|
4527
4680
|
const board = await openBoardDatabase(target.path);
|
|
@@ -5406,9 +5559,14 @@ function pageOf(cards, input) {
|
|
|
5406
5559
|
}
|
|
5407
5560
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
5408
5561
|
function projectKanboCardComment(comment) {
|
|
5562
|
+
const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
|
|
5563
|
+
const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
|
|
5564
|
+
const named = author !== "system" || decision;
|
|
5565
|
+
const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
|
|
5409
5566
|
return {
|
|
5410
5567
|
id: comment.id,
|
|
5411
|
-
author
|
|
5568
|
+
author,
|
|
5569
|
+
by: named || outside ? comment.authorId ?? author : "kanbo",
|
|
5412
5570
|
content: comment.content,
|
|
5413
5571
|
createdAt: comment.createdAt
|
|
5414
5572
|
};
|
|
@@ -5479,7 +5637,7 @@ function describeCardExtras(extras) {
|
|
|
5479
5637
|
})))].join("\n"));
|
|
5480
5638
|
if (extras.comments && extras.comments.length > 0) {
|
|
5481
5639
|
const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
|
|
5482
|
-
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
5640
|
+
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
5483
5641
|
}
|
|
5484
5642
|
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"));
|
|
5485
5643
|
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"));
|
|
@@ -5577,10 +5735,11 @@ function registerCardCommands(program) {
|
|
|
5577
5735
|
}, createPlacementActor()));
|
|
5578
5736
|
});
|
|
5579
5737
|
});
|
|
5580
|
-
withBoardOptions(card.command("update").description("Change a card's title, description, priority or
|
|
5738
|
+
withBoardOptions(card.command("update").description("Change a card's title, description, priority, labels or parent").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES$1.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode).option("--parent <card>", "the card to put it under; none puts it on the top level")).action(async (reference, options) => {
|
|
5581
5739
|
await runBoardCommand(options, "write", async (session) => {
|
|
5582
5740
|
const input = readUpdateInput(options);
|
|
5583
5741
|
const existing = await requireCard(session, reference);
|
|
5742
|
+
if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
|
|
5584
5743
|
return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
|
|
5585
5744
|
});
|
|
5586
5745
|
});
|
|
@@ -5721,9 +5880,10 @@ async function presentCard(session, card) {
|
|
|
5721
5880
|
};
|
|
5722
5881
|
}
|
|
5723
5882
|
/**
|
|
5724
|
-
* The fields `card update` was asked to change
|
|
5725
|
-
*
|
|
5726
|
-
*
|
|
5883
|
+
* The fields `card update` was asked to change — all but the parent, a card
|
|
5884
|
+
* of the board, looked up there once the card itself is. A command that names
|
|
5885
|
+
* none of them is refused rather than obeyed: an empty update still bumps the
|
|
5886
|
+
* board version, and every reader would be told the board changed when it did not.
|
|
5727
5887
|
*/
|
|
5728
5888
|
function readUpdateInput(options) {
|
|
5729
5889
|
const input = {};
|
|
@@ -5732,7 +5892,7 @@ function readUpdateInput(options) {
|
|
|
5732
5892
|
if (options.priority !== void 0) input.priority = options.priority;
|
|
5733
5893
|
if (options.labels !== void 0) input.labels = options.labels;
|
|
5734
5894
|
if (options.executionMode !== void 0) input.executionMode = options.executionMode;
|
|
5735
|
-
if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels
|
|
5895
|
+
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.");
|
|
5736
5896
|
return input;
|
|
5737
5897
|
}
|
|
5738
5898
|
function parsePriority(value) {
|
|
@@ -6002,6 +6162,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6002
6162
|
text: `No card "${named(details.issueId)}" here.`,
|
|
6003
6163
|
next: "kanbo card list"
|
|
6004
6164
|
}),
|
|
6165
|
+
issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
|
|
6005
6166
|
issue_parent_not_found: (details) => ({
|
|
6006
6167
|
text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
|
|
6007
6168
|
next: "kanbo card list"
|
|
@@ -6574,7 +6735,7 @@ dirname(fileURLToPath(import.meta.url));
|
|
|
6574
6735
|
* `package.version`. A build carries it as a literal; running from source
|
|
6575
6736
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
6576
6737
|
*/
|
|
6577
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
6738
|
+
const KANBO_PACKAGE_VERSION = "0.4.0";
|
|
6578
6739
|
//#endregion
|
|
6579
6740
|
//#region src/cli/setup/paths.ts
|
|
6580
6741
|
/**
|
|
@@ -6970,6 +7131,7 @@ const COMMAND_SHEET = [
|
|
|
6970
7131
|
"- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
|
|
6971
7132
|
"- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
|
|
6972
7133
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
7134
|
+
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
6973
7135
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
6974
7136
|
"- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
|
|
6975
7137
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
@@ -8055,7 +8217,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
8055
8217
|
setTimeout: globalThis.setTimeout,
|
|
8056
8218
|
clearTimeout: globalThis.clearTimeout
|
|
8057
8219
|
};
|
|
8058
|
-
const current = deps.currentVersion ?? "0.
|
|
8220
|
+
const current = deps.currentVersion ?? "0.4.0";
|
|
8059
8221
|
const controller = new AbortController();
|
|
8060
8222
|
let found = null;
|
|
8061
8223
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -9292,6 +9454,7 @@ async function applyInitPlan(plan) {
|
|
|
9292
9454
|
}
|
|
9293
9455
|
if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
|
|
9294
9456
|
const bindingPath = writeBinding(plan.projectDir, plan.binding);
|
|
9457
|
+
rememberProjectQuietly(plan.projectDir);
|
|
9295
9458
|
const files = plan.fileChanges.map(({ kind, change }) => ({
|
|
9296
9459
|
kind,
|
|
9297
9460
|
...applyFileChange(change)
|
|
@@ -10039,7 +10202,7 @@ async function planAgents(projectDir, agents, options) {
|
|
|
10039
10202
|
}
|
|
10040
10203
|
/** The board this project is bound to, and the workspace on it. */
|
|
10041
10204
|
async function resolveBoard(projectDir, options) {
|
|
10042
|
-
if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10205
|
+
if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10043
10206
|
const target = resolveInitTarget(projectDir, options);
|
|
10044
10207
|
if (target.kind === "postgres") return {
|
|
10045
10208
|
target,
|
|
@@ -10058,7 +10221,7 @@ async function resolveBoard(projectDir, options) {
|
|
|
10058
10221
|
* does — a person on a machine with nothing else installed gets a board, not a
|
|
10059
10222
|
* refusal that names a flag.
|
|
10060
10223
|
*/
|
|
10061
|
-
function namesNoBoard(projectDir, options) {
|
|
10224
|
+
function namesNoBoard$1(projectDir, options) {
|
|
10062
10225
|
return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
|
|
10063
10226
|
}
|
|
10064
10227
|
/** The workspace a host database — or the board file this project is already bound to — says this project is. */
|
|
@@ -10491,6 +10654,7 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
10491
10654
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
10492
10655
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
10493
10656
|
`- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
|
|
10657
|
+
`- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
|
|
10494
10658
|
`- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
|
|
10495
10659
|
`- ${PERSON_ONLY_RULE}`,
|
|
10496
10660
|
`- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
|
|
@@ -10870,61 +11034,6 @@ const cardCommentTool = defineKanboTool({
|
|
|
10870
11034
|
},
|
|
10871
11035
|
run: async (transport, input) => await transport.cardComment(input)
|
|
10872
11036
|
});
|
|
10873
|
-
//#endregion
|
|
10874
|
-
//#region src/mcp/tools/card-create.ts
|
|
10875
|
-
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
10876
|
-
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
10877
|
-
/** A new card, or a sub-card of the one being worked on. */
|
|
10878
|
-
const cardCreateTool = defineKanboTool({
|
|
10879
|
-
name: "kanbo_card_create",
|
|
10880
|
-
title: "Create a card",
|
|
10881
|
-
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
|
|
10882
|
-
inputSchema: {
|
|
10883
|
-
title: z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
10884
|
-
description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
10885
|
-
column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
10886
|
-
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
10887
|
-
executionMode: executionModeArgument.optional()
|
|
10888
|
-
},
|
|
10889
|
-
example: {
|
|
10890
|
-
description: "Add slugify(text) with a test",
|
|
10891
|
-
parent: "TST-1"
|
|
10892
|
-
},
|
|
10893
|
-
run: async (transport, input) => await transport.cardCreate(input)
|
|
10894
|
-
});
|
|
10895
|
-
//#endregion
|
|
10896
|
-
//#region src/mcp/tools/card-get.ts
|
|
10897
|
-
/** One card in full, with what an agent reads next to it. */
|
|
10898
|
-
const cardGetTool = defineKanboTool({
|
|
10899
|
-
name: "kanbo_card_get",
|
|
10900
|
-
title: "Read a card",
|
|
10901
|
-
description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
|
|
10902
|
-
inputSchema: {
|
|
10903
|
-
card: cardArgument,
|
|
10904
|
-
include: z.array(z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
10905
|
-
commentLimit: z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
10906
|
-
},
|
|
10907
|
-
example: { card: "TST-5" },
|
|
10908
|
-
run: async (transport, input) => await transport.cardGet(input)
|
|
10909
|
-
});
|
|
10910
|
-
//#endregion
|
|
10911
|
-
//#region src/mcp/tools/card-link-pr.ts
|
|
10912
|
-
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
10913
|
-
const cardLinkPrTool = defineKanboTool({
|
|
10914
|
-
name: "kanbo_card_link_pr",
|
|
10915
|
-
title: "Link a pull request to a card",
|
|
10916
|
-
description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
|
|
10917
|
-
inputSchema: {
|
|
10918
|
-
card: cardArgument,
|
|
10919
|
-
url: z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
10920
|
-
},
|
|
10921
|
-
needs: { url: "the pull request" },
|
|
10922
|
-
example: {
|
|
10923
|
-
card: "TST-5",
|
|
10924
|
-
url: "https://github.com/owner/repo/pull/12"
|
|
10925
|
-
},
|
|
10926
|
-
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
10927
|
-
});
|
|
10928
11037
|
/** The `detail` and `fields` arguments the list tools share. */
|
|
10929
11038
|
const cardListViewArguments = {
|
|
10930
11039
|
detail: z.enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
|
|
@@ -10963,6 +11072,20 @@ function compactKanboCard(card) {
|
|
|
10963
11072
|
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
10964
11073
|
};
|
|
10965
11074
|
}
|
|
11075
|
+
/** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
|
|
11076
|
+
const writtenCardDetailArgument = z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
|
|
11077
|
+
/** What each tool that writes a card says about its answer, in its description. */
|
|
11078
|
+
const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
|
|
11079
|
+
/**
|
|
11080
|
+
* The card a write hands back, as the tool answers with it: compact and on one
|
|
11081
|
+
* line, as a list prints it. An agent writes its card at every step — a status
|
|
11082
|
+
* line, a move, an update — and reads each answer again on every later turn,
|
|
11083
|
+
* so a whole card, description included, would cost it again and again.
|
|
11084
|
+
* `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
|
|
11085
|
+
*/
|
|
11086
|
+
function formatWrittenKanboCard(card, detail) {
|
|
11087
|
+
return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
|
|
11088
|
+
}
|
|
10966
11089
|
function viewCard(card, view) {
|
|
10967
11090
|
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
10968
11091
|
return view.detail === "full" ? card : compactKanboCard(card);
|
|
@@ -10986,6 +11109,62 @@ function formatKanboCardPage(page, view, returned) {
|
|
|
10986
11109
|
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)}`;
|
|
10987
11110
|
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
10988
11111
|
}
|
|
11112
|
+
//#endregion
|
|
11113
|
+
//#region src/mcp/tools/card-create.ts
|
|
11114
|
+
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
11115
|
+
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
11116
|
+
/** A new card, or a sub-card of the one being worked on. */
|
|
11117
|
+
const cardCreateTool = defineKanboTool({
|
|
11118
|
+
name: "kanbo_card_create",
|
|
11119
|
+
title: "Create a card",
|
|
11120
|
+
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.`,
|
|
11121
|
+
inputSchema: {
|
|
11122
|
+
title: z.string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
11123
|
+
description: z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
11124
|
+
column: z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
11125
|
+
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
11126
|
+
executionMode: executionModeArgument.optional(),
|
|
11127
|
+
detail: writtenCardDetailArgument
|
|
11128
|
+
},
|
|
11129
|
+
example: {
|
|
11130
|
+
description: "Add slugify(text) with a test",
|
|
11131
|
+
parent: "TST-1"
|
|
11132
|
+
},
|
|
11133
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
|
|
11134
|
+
});
|
|
11135
|
+
//#endregion
|
|
11136
|
+
//#region src/mcp/tools/card-get.ts
|
|
11137
|
+
/** One card in full, with what an agent reads next to it. */
|
|
11138
|
+
const cardGetTool = defineKanboTool({
|
|
11139
|
+
name: "kanbo_card_get",
|
|
11140
|
+
title: "Read a card",
|
|
11141
|
+
description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
|
|
11142
|
+
inputSchema: {
|
|
11143
|
+
card: cardArgument,
|
|
11144
|
+
include: z.array(z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
11145
|
+
commentLimit: z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
11146
|
+
},
|
|
11147
|
+
example: { card: "TST-5" },
|
|
11148
|
+
run: async (transport, input) => await transport.cardGet(input)
|
|
11149
|
+
});
|
|
11150
|
+
//#endregion
|
|
11151
|
+
//#region src/mcp/tools/card-link-pr.ts
|
|
11152
|
+
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
11153
|
+
const cardLinkPrTool = defineKanboTool({
|
|
11154
|
+
name: "kanbo_card_link_pr",
|
|
11155
|
+
title: "Link a pull request to a card",
|
|
11156
|
+
description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
|
|
11157
|
+
inputSchema: {
|
|
11158
|
+
card: cardArgument,
|
|
11159
|
+
url: z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
11160
|
+
},
|
|
11161
|
+
needs: { url: "the pull request" },
|
|
11162
|
+
example: {
|
|
11163
|
+
card: "TST-5",
|
|
11164
|
+
url: "https://github.com/owner/repo/pull/12"
|
|
11165
|
+
},
|
|
11166
|
+
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
11167
|
+
});
|
|
10989
11168
|
/** The cards on the board a question picks, one page of them. */
|
|
10990
11169
|
const cardListTool = defineKanboTool({
|
|
10991
11170
|
name: "kanbo_card_list",
|
|
@@ -11040,10 +11219,11 @@ function readMoment(value) {
|
|
|
11040
11219
|
const cardMoveTool = defineKanboTool({
|
|
11041
11220
|
name: "kanbo_card_move",
|
|
11042
11221
|
title: "Move a card",
|
|
11043
|
-
description:
|
|
11222
|
+
description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column with entryRules refuses a card that does not meet them yet, listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
11044
11223
|
inputSchema: {
|
|
11045
11224
|
card: cardArgument,
|
|
11046
|
-
column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
|
|
11225
|
+
column: z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
11226
|
+
detail: writtenCardDetailArgument
|
|
11047
11227
|
},
|
|
11048
11228
|
aliases: { column: ["to"] },
|
|
11049
11229
|
needs: { column: "the column slug, e.g. in_progress" },
|
|
@@ -11051,7 +11231,7 @@ const cardMoveTool = defineKanboTool({
|
|
|
11051
11231
|
card: "TST-5",
|
|
11052
11232
|
column: "in_progress"
|
|
11053
11233
|
},
|
|
11054
|
-
run: async (transport, input) => await transport.cardMove(input)
|
|
11234
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
|
|
11055
11235
|
});
|
|
11056
11236
|
//#endregion
|
|
11057
11237
|
//#region src/mcp/tools/card-pull-requests.ts
|
|
@@ -11066,11 +11246,11 @@ const cardPullRequestsTool = defineKanboTool({
|
|
|
11066
11246
|
});
|
|
11067
11247
|
//#endregion
|
|
11068
11248
|
//#region src/mcp/tools/card-update.ts
|
|
11069
|
-
/** What a card says
|
|
11249
|
+
/** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
|
|
11070
11250
|
const cardUpdateTool = defineKanboTool({
|
|
11071
11251
|
name: "kanbo_card_update",
|
|
11072
11252
|
title: "Update a card",
|
|
11073
|
-
description:
|
|
11253
|
+
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}`,
|
|
11074
11254
|
inputSchema: {
|
|
11075
11255
|
card: cardArgument,
|
|
11076
11256
|
title: z.string().min(1).optional().describe("What the card is called."),
|
|
@@ -11083,18 +11263,26 @@ const cardUpdateTool = defineKanboTool({
|
|
|
11083
11263
|
"urgent"
|
|
11084
11264
|
]).optional(),
|
|
11085
11265
|
labels: z.array(z.string()).optional().describe("The labels the card carries, replacing the ones it has."),
|
|
11086
|
-
executionMode: executionModeArgument.optional()
|
|
11266
|
+
executionMode: executionModeArgument.optional(),
|
|
11267
|
+
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."),
|
|
11268
|
+
detail: writtenCardDetailArgument
|
|
11087
11269
|
},
|
|
11088
11270
|
example: {
|
|
11089
11271
|
card: "TST-5",
|
|
11090
11272
|
title: "Add slugify"
|
|
11091
11273
|
},
|
|
11092
|
-
|
|
11093
|
-
|
|
11094
|
-
return
|
|
11274
|
+
prepare: (input) => {
|
|
11275
|
+
const { parent, ...rest } = input;
|
|
11276
|
+
return parent === null ? rest : input;
|
|
11277
|
+
},
|
|
11278
|
+
run: async (transport, { card, detail, parent, ...patch }) => {
|
|
11279
|
+
if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
|
|
11280
|
+
const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
|
|
11281
|
+
return formatWrittenKanboCard(await transport.cardUpdate({
|
|
11095
11282
|
card,
|
|
11096
|
-
...patch
|
|
11097
|
-
|
|
11283
|
+
...patch,
|
|
11284
|
+
...placement
|
|
11285
|
+
}), detail);
|
|
11098
11286
|
}
|
|
11099
11287
|
});
|
|
11100
11288
|
//#endregion
|
|
@@ -11231,10 +11419,11 @@ const KANBO_TOOLS = [
|
|
|
11231
11419
|
defineKanboTool({
|
|
11232
11420
|
name: "kanbo_status_line",
|
|
11233
11421
|
title: "Set the status line",
|
|
11234
|
-
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.",
|
|
11422
|
+
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,
|
|
11235
11423
|
inputSchema: {
|
|
11236
11424
|
card: cardArgument,
|
|
11237
|
-
text: z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
|
|
11425
|
+
text: z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
|
|
11426
|
+
detail: writtenCardDetailArgument
|
|
11238
11427
|
},
|
|
11239
11428
|
aliases: { text: ["content"] },
|
|
11240
11429
|
needs: { text: "the status line, one sentence" },
|
|
@@ -11242,22 +11431,23 @@ const KANBO_TOOLS = [
|
|
|
11242
11431
|
card: "TST-5",
|
|
11243
11432
|
text: "Running the tests"
|
|
11244
11433
|
},
|
|
11245
|
-
run: async (transport, input) => await transport.statusLine(input)
|
|
11434
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
|
|
11246
11435
|
}),
|
|
11247
11436
|
defineKanboTool({
|
|
11248
11437
|
name: "kanbo_wait_approval",
|
|
11249
11438
|
title: "Hand the card to a person",
|
|
11250
|
-
description:
|
|
11439
|
+
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}`,
|
|
11251
11440
|
inputSchema: {
|
|
11252
11441
|
card: cardArgument,
|
|
11253
|
-
text: z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
|
|
11442
|
+
text: z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
|
|
11443
|
+
detail: writtenCardDetailArgument
|
|
11254
11444
|
},
|
|
11255
11445
|
aliases: { text: ["content"] },
|
|
11256
11446
|
example: {
|
|
11257
11447
|
card: "TST-5",
|
|
11258
11448
|
text: "Ready for review: slugify added with a test"
|
|
11259
11449
|
},
|
|
11260
|
-
run: async (transport, input) => await transport.waitApproval(input)
|
|
11450
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
|
|
11261
11451
|
}),
|
|
11262
11452
|
runStartTool,
|
|
11263
11453
|
runFinishTool
|
|
@@ -11458,10 +11648,14 @@ async function createDbTransport(input) {
|
|
|
11458
11648
|
executionMode
|
|
11459
11649
|
}, actor));
|
|
11460
11650
|
},
|
|
11461
|
-
cardUpdate: async ({ card, ...patch }) => {
|
|
11651
|
+
cardUpdate: async ({ card, parent, ...patch }) => {
|
|
11462
11652
|
await session.assertWritable();
|
|
11463
11653
|
const existing = await resolveCard(card);
|
|
11464
|
-
|
|
11654
|
+
const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
|
|
11655
|
+
return await project(await ops.updateCard(existing.id, {
|
|
11656
|
+
...patch,
|
|
11657
|
+
...placement
|
|
11658
|
+
}, actor));
|
|
11465
11659
|
},
|
|
11466
11660
|
cardMove: async ({ card, column }) => {
|
|
11467
11661
|
await session.assertWritable();
|
|
@@ -11615,13 +11809,40 @@ function withUpdateNote(board, peek) {
|
|
|
11615
11809
|
}
|
|
11616
11810
|
//#endregion
|
|
11617
11811
|
//#region src/cli/commands/mcp.ts
|
|
11812
|
+
/**
|
|
11813
|
+
* The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
|
|
11814
|
+
* else the agent tool, else `agent` until the client says its name (see
|
|
11815
|
+
* `nameActorAfterClient`).
|
|
11816
|
+
*/
|
|
11817
|
+
function createMcpActor() {
|
|
11818
|
+
const tool = readAgentTool();
|
|
11819
|
+
const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
|
|
11820
|
+
const actor = createCliActor();
|
|
11821
|
+
if (!namedByShell) actor.id = "agent";
|
|
11822
|
+
return {
|
|
11823
|
+
actor,
|
|
11824
|
+
namedByShell
|
|
11825
|
+
};
|
|
11826
|
+
}
|
|
11827
|
+
/**
|
|
11828
|
+
* Files the session's writes under the client's own name (`Test Client` ->
|
|
11829
|
+
* `test-client`) once it has initialized. `actor` is the object the board
|
|
11830
|
+
* holds, so later writes see the new id.
|
|
11831
|
+
*/
|
|
11832
|
+
function nameActorAfterClient(server, actor) {
|
|
11833
|
+
server.server.oninitialized = () => {
|
|
11834
|
+
const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
|
|
11835
|
+
if (id) actor.id = id;
|
|
11836
|
+
};
|
|
11837
|
+
}
|
|
11618
11838
|
function registerMcpCommand(program) {
|
|
11619
11839
|
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) => {
|
|
11840
|
+
const { actor, namedByShell } = createMcpActor();
|
|
11620
11841
|
const board = await openMcpBoard({
|
|
11621
11842
|
dbPath: options.db,
|
|
11622
11843
|
databaseUrl: options.databaseUrl,
|
|
11623
11844
|
workspaceId: options.workspace,
|
|
11624
|
-
actor
|
|
11845
|
+
actor
|
|
11625
11846
|
});
|
|
11626
11847
|
let closing;
|
|
11627
11848
|
const close = () => closing ??= board.close().catch((error) => {
|
|
@@ -11630,6 +11851,7 @@ function registerMcpCommand(program) {
|
|
|
11630
11851
|
const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
|
|
11631
11852
|
const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
|
|
11632
11853
|
server.server.onclose = () => void close();
|
|
11854
|
+
if (!namedByShell) nameActorAfterClient(server, actor);
|
|
11633
11855
|
try {
|
|
11634
11856
|
await server.connect(new StdioServerTransport());
|
|
11635
11857
|
} catch (error) {
|
|
@@ -12274,6 +12496,10 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12274
12496
|
status: 404,
|
|
12275
12497
|
message: "Issue not found"
|
|
12276
12498
|
},
|
|
12499
|
+
issue_parent_cycle: {
|
|
12500
|
+
status: 400,
|
|
12501
|
+
message: "Issue cannot be put under one of its own sub-issues"
|
|
12502
|
+
},
|
|
12277
12503
|
issue_parent_not_found: {
|
|
12278
12504
|
status: 404,
|
|
12279
12505
|
message: "Parent issue not found"
|
|
@@ -12655,10 +12881,20 @@ function toStatusView(status) {
|
|
|
12655
12881
|
entryRules: readEntryRules(status)
|
|
12656
12882
|
};
|
|
12657
12883
|
}
|
|
12658
|
-
function toCommentView(comment) {
|
|
12884
|
+
function toCommentView(comment, personWriterId = null) {
|
|
12885
|
+
if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
|
|
12886
|
+
...comment,
|
|
12887
|
+
author: {
|
|
12888
|
+
kind: "user",
|
|
12889
|
+
id: comment.authorId ?? "__self__",
|
|
12890
|
+
displayName: "You",
|
|
12891
|
+
avatarUrl: null,
|
|
12892
|
+
label: null
|
|
12893
|
+
}
|
|
12894
|
+
};
|
|
12659
12895
|
return {
|
|
12660
12896
|
...comment,
|
|
12661
|
-
author: resolveActor(commentAuthorReference(comment))
|
|
12897
|
+
author: resolveActor(commentAuthorReference(comment), personWriterId)
|
|
12662
12898
|
};
|
|
12663
12899
|
}
|
|
12664
12900
|
/**
|
|
@@ -12666,23 +12902,34 @@ function toCommentView(comment) {
|
|
|
12666
12902
|
*
|
|
12667
12903
|
* For the kinds that need nobody else's tables: an outside writer is shown
|
|
12668
12904
|
* under the name it gave, the board writing for itself is `kanbo`, and a person
|
|
12669
|
-
* is `You`.
|
|
12905
|
+
* is `You`. So is an outside writer that is the person who started this
|
|
12906
|
+
* server (`personWriterId`): their own writes carry the name their shell files
|
|
12907
|
+
* them under. An agent and a provider target are rows of whatever app launched
|
|
12670
12908
|
* them, which this server cannot read, so they are shown by the id the board
|
|
12671
12909
|
* holds for them.
|
|
12672
12910
|
*/
|
|
12673
|
-
function resolveActor(actor) {
|
|
12911
|
+
function resolveActor(actor, personWriterId) {
|
|
12674
12912
|
if (actor.kind === "system") {
|
|
12675
12913
|
const provenance = {
|
|
12676
12914
|
authorId: actor.id,
|
|
12677
12915
|
sourceChatSessionId: actor.sourceChatSessionId
|
|
12678
12916
|
};
|
|
12679
|
-
if (isOutsideWriter(provenance))
|
|
12680
|
-
|
|
12681
|
-
|
|
12682
|
-
|
|
12683
|
-
|
|
12684
|
-
|
|
12685
|
-
|
|
12917
|
+
if (isOutsideWriter(provenance)) {
|
|
12918
|
+
if (personWriterId !== null && provenance.authorId === personWriterId) return {
|
|
12919
|
+
kind: "user",
|
|
12920
|
+
id: provenance.authorId,
|
|
12921
|
+
displayName: "You",
|
|
12922
|
+
avatarUrl: null,
|
|
12923
|
+
label: null
|
|
12924
|
+
};
|
|
12925
|
+
return {
|
|
12926
|
+
kind: "system",
|
|
12927
|
+
id: provenance.authorId,
|
|
12928
|
+
displayName: provenance.authorId,
|
|
12929
|
+
avatarUrl: null,
|
|
12930
|
+
label: "External"
|
|
12931
|
+
};
|
|
12932
|
+
}
|
|
12686
12933
|
return {
|
|
12687
12934
|
kind: "system",
|
|
12688
12935
|
id: null,
|
|
@@ -12718,13 +12965,13 @@ function resolveActor(actor) {
|
|
|
12718
12965
|
* serves, named by its identity; actors as `resolveActor` reads them; and an
|
|
12719
12966
|
* agent assignee by its id.
|
|
12720
12967
|
*/
|
|
12721
|
-
function createActivityResolver(workspace) {
|
|
12968
|
+
function createActivityResolver(workspace, personWriterId = null) {
|
|
12722
12969
|
return {
|
|
12723
12970
|
workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
|
|
12724
12971
|
id: workspace.id,
|
|
12725
12972
|
name: workspace.name
|
|
12726
12973
|
}]]),
|
|
12727
|
-
resolveActor: async (actor) => resolveActor(actor),
|
|
12974
|
+
resolveActor: async (actor) => resolveActor(actor, personWriterId),
|
|
12728
12975
|
resolveAgentName: async (agentId) => agentId
|
|
12729
12976
|
};
|
|
12730
12977
|
}
|
|
@@ -12745,10 +12992,15 @@ function createActivityResolver(workspace) {
|
|
|
12745
12992
|
const OK = { ok: true };
|
|
12746
12993
|
/** How many cards a search answers with when the caller names no limit. */
|
|
12747
12994
|
const DEFAULT_SEARCH_LIMIT = 20;
|
|
12748
|
-
|
|
12995
|
+
/**
|
|
12996
|
+
* `personWriterId` is the id the person who started the server is filed under;
|
|
12997
|
+
* their writes read as `You`, everyone else's by name.
|
|
12998
|
+
*/
|
|
12999
|
+
function createIssueRoutes(session, options = {}) {
|
|
13000
|
+
const personWriterId = options.personWriterId ?? null;
|
|
12749
13001
|
const { ops, store, workspace } = session;
|
|
12750
13002
|
const workspaceId = workspace.id;
|
|
12751
|
-
const activityResolver = createActivityResolver(workspace);
|
|
13003
|
+
const activityResolver = createActivityResolver(workspace, personWriterId);
|
|
12752
13004
|
/** The served workspace, when that is the one the caller named — or named none. */
|
|
12753
13005
|
function requireWorkspace(named) {
|
|
12754
13006
|
if (named !== void 0 && named !== workspaceId) throw new BoardError("issue_workspace_not_found", { workspaceId: named });
|
|
@@ -13033,6 +13285,15 @@ function createIssueRoutes(session) {
|
|
|
13033
13285
|
return updated;
|
|
13034
13286
|
}
|
|
13035
13287
|
}),
|
|
13288
|
+
route({
|
|
13289
|
+
method: "GET",
|
|
13290
|
+
path: "/issues/workspace",
|
|
13291
|
+
handle: async () => ({
|
|
13292
|
+
id: workspace.id,
|
|
13293
|
+
identifier: workspace.identifier,
|
|
13294
|
+
name: workspace.name
|
|
13295
|
+
})
|
|
13296
|
+
}),
|
|
13036
13297
|
route({
|
|
13037
13298
|
method: "GET",
|
|
13038
13299
|
path: "/issues/:id",
|
|
@@ -13186,7 +13447,7 @@ function createIssueRoutes(session) {
|
|
|
13186
13447
|
path: "/issues/:id/comments",
|
|
13187
13448
|
handle: async ({ params }) => {
|
|
13188
13449
|
await requireCard(params.id);
|
|
13189
|
-
return (await ops.listComments(params.id)).map(toCommentView);
|
|
13450
|
+
return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
|
|
13190
13451
|
}
|
|
13191
13452
|
}),
|
|
13192
13453
|
route({
|
|
@@ -13199,7 +13460,7 @@ function createIssueRoutes(session) {
|
|
|
13199
13460
|
return toCommentView(await ops.addComment({
|
|
13200
13461
|
issueId: params.id,
|
|
13201
13462
|
content: body.content
|
|
13202
|
-
}, actors.
|
|
13463
|
+
}, actors.person), personWriterId);
|
|
13203
13464
|
}
|
|
13204
13465
|
}),
|
|
13205
13466
|
route({
|
|
@@ -13328,7 +13589,7 @@ function assertServeBinding(host, token) {
|
|
|
13328
13589
|
}
|
|
13329
13590
|
async function startKanboServer(options) {
|
|
13330
13591
|
assertServeBinding(options.host, options.token);
|
|
13331
|
-
const routes = createIssueRoutes(options.session);
|
|
13592
|
+
const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
|
|
13332
13593
|
const listenHost = stripBrackets(options.host);
|
|
13333
13594
|
let boundPort = 0;
|
|
13334
13595
|
const handle = createRequestHandler(routes, options, () => boundPort);
|
|
@@ -13374,6 +13635,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
|
13374
13635
|
"HEAD",
|
|
13375
13636
|
"OPTIONS"
|
|
13376
13637
|
]);
|
|
13638
|
+
/** What the board page tells the server about itself. */
|
|
13639
|
+
const PRESENCE_BODY = z.object({
|
|
13640
|
+
tabId: z.string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
|
|
13641
|
+
state: z.enum(["open", "closed"])
|
|
13642
|
+
});
|
|
13377
13643
|
function createRequestHandler(routes, options, boundPort) {
|
|
13378
13644
|
const token = options.token || null;
|
|
13379
13645
|
const corsOrigins = new Set(options.corsOrigins ?? []);
|
|
@@ -13434,17 +13700,41 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13434
13700
|
throw new ServeError(401, "unauthorized", "A valid bearer token is required");
|
|
13435
13701
|
}
|
|
13436
13702
|
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");
|
|
13437
|
-
|
|
13438
|
-
|
|
13703
|
+
if (url.pathname === "/page/presence" && request.method === "POST") {
|
|
13704
|
+
if (!options.presence) {
|
|
13705
|
+
response.writeHead(204).end();
|
|
13706
|
+
return;
|
|
13707
|
+
}
|
|
13708
|
+
const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
|
|
13709
|
+
if (!parsed.success) throw validationError("body", parsed.error);
|
|
13710
|
+
if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
|
|
13711
|
+
else options.presence.closed(parsed.data.tabId);
|
|
13712
|
+
sendJson(response, 200, { ok: true });
|
|
13713
|
+
return;
|
|
13714
|
+
}
|
|
13715
|
+
const projectPath = readProjectPath(url.pathname);
|
|
13716
|
+
if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
|
|
13717
|
+
sendJson(response, 200, options.projects.list());
|
|
13718
|
+
return;
|
|
13719
|
+
}
|
|
13720
|
+
const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
|
|
13721
|
+
if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
|
|
13722
|
+
if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
|
|
13439
13723
|
const body = await readJsonBody(request);
|
|
13440
13724
|
const actors = resolveRequestActors(options.actors, {
|
|
13441
13725
|
actorHeader: request.headers[ACTOR_HEADER],
|
|
13442
13726
|
authenticated: token !== null
|
|
13443
13727
|
});
|
|
13444
13728
|
const result = queue.then(async () => {
|
|
13445
|
-
|
|
13446
|
-
|
|
13447
|
-
|
|
13729
|
+
const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
|
|
13730
|
+
session: options.session,
|
|
13731
|
+
routes
|
|
13732
|
+
};
|
|
13733
|
+
const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
|
|
13734
|
+
if (!found) throw new ServeError(404, "not_found", "Not Found");
|
|
13735
|
+
if (found.route.write) await target.session.assertWritable();
|
|
13736
|
+
return await found.route.run({
|
|
13737
|
+
params: found.params,
|
|
13448
13738
|
query: readQuery(url.searchParams),
|
|
13449
13739
|
body,
|
|
13450
13740
|
actors
|
|
@@ -13470,6 +13760,21 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13470
13760
|
}
|
|
13471
13761
|
};
|
|
13472
13762
|
}
|
|
13763
|
+
/** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
|
|
13764
|
+
function readProjectPath(pathname) {
|
|
13765
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
13766
|
+
if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
|
|
13767
|
+
let projectId;
|
|
13768
|
+
try {
|
|
13769
|
+
projectId = decodeURIComponent(segments[1]);
|
|
13770
|
+
} catch {
|
|
13771
|
+
return null;
|
|
13772
|
+
}
|
|
13773
|
+
return {
|
|
13774
|
+
projectId,
|
|
13775
|
+
rest: `/${segments.slice(2).join("/")}`
|
|
13776
|
+
};
|
|
13777
|
+
}
|
|
13473
13778
|
function isJsonContentType(value) {
|
|
13474
13779
|
return value?.split(";")[0].trim().toLowerCase() === "application/json";
|
|
13475
13780
|
}
|
|
@@ -13613,6 +13918,146 @@ function readTextOrNull(path) {
|
|
|
13613
13918
|
}
|
|
13614
13919
|
}
|
|
13615
13920
|
//#endregion
|
|
13921
|
+
//#region src/serve/page-presence.ts
|
|
13922
|
+
function createPagePresence(options) {
|
|
13923
|
+
const closedGraceMs = options.closedGraceMs ?? 5e3;
|
|
13924
|
+
const staleMs = options.staleMs ?? 12e4;
|
|
13925
|
+
const tickMs = options.tickMs ?? 1e4;
|
|
13926
|
+
const now = options.now ?? Date.now;
|
|
13927
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
13928
|
+
let finished = false;
|
|
13929
|
+
let graceTimer = null;
|
|
13930
|
+
let lastTick = now();
|
|
13931
|
+
function stop() {
|
|
13932
|
+
finished = true;
|
|
13933
|
+
clearInterval(ticker);
|
|
13934
|
+
if (graceTimer) {
|
|
13935
|
+
clearTimeout(graceTimer);
|
|
13936
|
+
graceTimer = null;
|
|
13937
|
+
}
|
|
13938
|
+
}
|
|
13939
|
+
function idle() {
|
|
13940
|
+
if (finished) return;
|
|
13941
|
+
stop();
|
|
13942
|
+
options.onIdle();
|
|
13943
|
+
}
|
|
13944
|
+
const ticker = setInterval(() => {
|
|
13945
|
+
const current = now();
|
|
13946
|
+
const asleep = current - lastTick > 3 * tickMs;
|
|
13947
|
+
lastTick = current;
|
|
13948
|
+
if (asleep) {
|
|
13949
|
+
for (const id of tabs.keys()) tabs.set(id, current);
|
|
13950
|
+
return;
|
|
13951
|
+
}
|
|
13952
|
+
if (tabs.size === 0) return;
|
|
13953
|
+
for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
|
|
13954
|
+
if (tabs.size === 0) idle();
|
|
13955
|
+
}, tickMs);
|
|
13956
|
+
ticker.unref();
|
|
13957
|
+
return {
|
|
13958
|
+
seen: (tabId) => {
|
|
13959
|
+
if (!finished) tabs.set(tabId, now());
|
|
13960
|
+
},
|
|
13961
|
+
closed: (tabId) => {
|
|
13962
|
+
if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
|
|
13963
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
13964
|
+
graceTimer = setTimeout(() => {
|
|
13965
|
+
graceTimer = null;
|
|
13966
|
+
if (tabs.size === 0) idle();
|
|
13967
|
+
}, closedGraceMs);
|
|
13968
|
+
graceTimer.unref();
|
|
13969
|
+
},
|
|
13970
|
+
stop
|
|
13971
|
+
};
|
|
13972
|
+
}
|
|
13973
|
+
//#endregion
|
|
13974
|
+
//#region src/serve/projects.ts
|
|
13975
|
+
function createServeProjects(input) {
|
|
13976
|
+
const routesOptions = { personWriterId: input.personWriterId ?? null };
|
|
13977
|
+
const opened = /* @__PURE__ */ new Map();
|
|
13978
|
+
let defaultOpened = null;
|
|
13979
|
+
/** Every project that can be offered right now, default first, with the folder each lives in. */
|
|
13980
|
+
function entries() {
|
|
13981
|
+
const defaultDir = input.defaultProject?.dir ?? null;
|
|
13982
|
+
const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
|
|
13983
|
+
const found = [];
|
|
13984
|
+
for (const dir of dirs) {
|
|
13985
|
+
const binding = input.readBinding(dir);
|
|
13986
|
+
if (binding === null && dir !== defaultDir) continue;
|
|
13987
|
+
found.push({
|
|
13988
|
+
view: viewOf(dir, binding, dir === defaultDir),
|
|
13989
|
+
dir
|
|
13990
|
+
});
|
|
13991
|
+
}
|
|
13992
|
+
return found;
|
|
13993
|
+
}
|
|
13994
|
+
return {
|
|
13995
|
+
list: () => entries().map((entry) => entry.view),
|
|
13996
|
+
resolve: async (projectId) => {
|
|
13997
|
+
const entry = entries().find((candidate) => candidate.view.id === projectId);
|
|
13998
|
+
if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
|
|
13999
|
+
if (entry.view.isDefault && input.defaultProject) {
|
|
14000
|
+
defaultOpened ??= {
|
|
14001
|
+
session: input.defaultProject.session,
|
|
14002
|
+
routes: createIssueRoutes(input.defaultProject.session, routesOptions)
|
|
14003
|
+
};
|
|
14004
|
+
return defaultOpened;
|
|
14005
|
+
}
|
|
14006
|
+
const cached = opened.get(projectId);
|
|
14007
|
+
if (cached) return cached;
|
|
14008
|
+
let session;
|
|
14009
|
+
try {
|
|
14010
|
+
session = await input.openSession(entry.dir);
|
|
14011
|
+
} catch (error) {
|
|
14012
|
+
throw new ServeError(503, "project_unavailable", describeFailure(error).message);
|
|
14013
|
+
}
|
|
14014
|
+
const project = {
|
|
14015
|
+
session,
|
|
14016
|
+
routes: createIssueRoutes(session, routesOptions)
|
|
14017
|
+
};
|
|
14018
|
+
opened.set(projectId, project);
|
|
14019
|
+
return project;
|
|
14020
|
+
},
|
|
14021
|
+
close: async () => {
|
|
14022
|
+
const sessions = [...opened.values()].map((project) => project.session);
|
|
14023
|
+
opened.clear();
|
|
14024
|
+
await Promise.allSettled(sessions.map(async (session) => await session.close()));
|
|
14025
|
+
}
|
|
14026
|
+
};
|
|
14027
|
+
}
|
|
14028
|
+
function projectId(dir) {
|
|
14029
|
+
return createHash("sha256").update(dir).digest("hex").slice(0, 12);
|
|
14030
|
+
}
|
|
14031
|
+
function viewOf(dir, binding, isDefault) {
|
|
14032
|
+
return {
|
|
14033
|
+
id: projectId(dir),
|
|
14034
|
+
name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
|
|
14035
|
+
folder: tildify(dir),
|
|
14036
|
+
storage: storageOf(dir, binding),
|
|
14037
|
+
isDefault
|
|
14038
|
+
};
|
|
14039
|
+
}
|
|
14040
|
+
/** Where the board lives. The connection string is read here to name its host, and goes no further. */
|
|
14041
|
+
function storageOf(dir, binding) {
|
|
14042
|
+
if (binding?.databaseUrl) return {
|
|
14043
|
+
kind: "postgres",
|
|
14044
|
+
where: hostOf(maskDatabaseUrl(binding.databaseUrl))
|
|
14045
|
+
};
|
|
14046
|
+
return {
|
|
14047
|
+
kind: "file",
|
|
14048
|
+
path: tildify(resolve(dir, binding?.dbPath ?? ".kanbo/board.db"))
|
|
14049
|
+
};
|
|
14050
|
+
}
|
|
14051
|
+
/** The host of an already masked connection string; a string that is not one names no host. */
|
|
14052
|
+
function hostOf(maskedUrl) {
|
|
14053
|
+
try {
|
|
14054
|
+
const { host } = new URL(maskedUrl);
|
|
14055
|
+
return host && !host.includes("*") ? host : "a Postgres database";
|
|
14056
|
+
} catch {
|
|
14057
|
+
return "a Postgres database";
|
|
14058
|
+
}
|
|
14059
|
+
}
|
|
14060
|
+
//#endregion
|
|
13616
14061
|
//#region src/cli/commands/serve.ts
|
|
13617
14062
|
function registerServeCommand(program) {
|
|
13618
14063
|
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) => {
|
|
@@ -13620,7 +14065,7 @@ function registerServeCommand(program) {
|
|
|
13620
14065
|
status: (line) => console.error(line),
|
|
13621
14066
|
page: (line) => console.log(line)
|
|
13622
14067
|
});
|
|
13623
|
-
await
|
|
14068
|
+
if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
|
|
13624
14069
|
try {
|
|
13625
14070
|
await running.close();
|
|
13626
14071
|
process.exit(0);
|
|
@@ -13632,26 +14077,46 @@ function registerServeCommand(program) {
|
|
|
13632
14077
|
}
|
|
13633
14078
|
/**
|
|
13634
14079
|
* Open the board and serve it — everything `kanbo serve` does but wait. The
|
|
13635
|
-
* command waits for Ctrl-C
|
|
13636
|
-
*
|
|
14080
|
+
* command waits for Ctrl-C (or the last board tab closing) and ends the
|
|
14081
|
+
* process; the home menu waits the same and comes back to its menu.
|
|
13637
14082
|
*/
|
|
13638
14083
|
async function startServe(options, output) {
|
|
13639
14084
|
const port = parsePort(options.port);
|
|
13640
14085
|
const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
|
|
14086
|
+
const here = findBinding(process.cwd());
|
|
14087
|
+
const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
|
|
14088
|
+
const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
|
|
13641
14089
|
const session = await openBoardSession({
|
|
13642
14090
|
db: options.db,
|
|
13643
14091
|
databaseUrl: options.databaseUrl,
|
|
13644
14092
|
workspace: options.workspace
|
|
13645
|
-
}, "read");
|
|
14093
|
+
}, "read", mostRecent?.path);
|
|
13646
14094
|
const agentShell = isAgentShell();
|
|
13647
14095
|
const actors = {
|
|
13648
|
-
writer:
|
|
14096
|
+
writer: createServeWriter(),
|
|
13649
14097
|
person: agentShell ? null : createPersonActor()
|
|
13650
14098
|
};
|
|
14099
|
+
const projects = createServeProjects({
|
|
14100
|
+
defaultProject: defaultDir === null ? null : {
|
|
14101
|
+
dir: defaultDir,
|
|
14102
|
+
session
|
|
14103
|
+
},
|
|
14104
|
+
readRegistry: () => readProjectRegistry(),
|
|
14105
|
+
readBinding: (dir) => readProjectBinding(dir),
|
|
14106
|
+
openSession: async (dir) => await openBoardSession({}, "read", dir),
|
|
14107
|
+
personWriterId: actors.writer.id
|
|
14108
|
+
});
|
|
14109
|
+
const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
|
|
14110
|
+
let presence;
|
|
14111
|
+
const idle = new Promise((resolve) => {
|
|
14112
|
+
presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
|
|
14113
|
+
});
|
|
13651
14114
|
let server;
|
|
13652
14115
|
try {
|
|
13653
14116
|
server = await startKanboServer({
|
|
13654
14117
|
session,
|
|
14118
|
+
projects,
|
|
14119
|
+
presence,
|
|
13655
14120
|
actors,
|
|
13656
14121
|
host: options.host,
|
|
13657
14122
|
port,
|
|
@@ -13659,10 +14124,12 @@ async function startServe(options, output) {
|
|
|
13659
14124
|
corsOrigins: options.corsOrigin
|
|
13660
14125
|
});
|
|
13661
14126
|
} catch (error) {
|
|
14127
|
+
presence?.stop();
|
|
13662
14128
|
await session.close();
|
|
13663
14129
|
throw error;
|
|
13664
14130
|
}
|
|
13665
|
-
|
|
14131
|
+
const projectCount = projects.list().length;
|
|
14132
|
+
output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
|
|
13666
14133
|
output.status(`kanbo serve: ${describePersonRights(actors)}`);
|
|
13667
14134
|
presentServePage({
|
|
13668
14135
|
url: server.url,
|
|
@@ -13676,25 +14143,40 @@ async function startServe(options, output) {
|
|
|
13676
14143
|
});
|
|
13677
14144
|
return {
|
|
13678
14145
|
link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
|
|
14146
|
+
idle,
|
|
13679
14147
|
close: async () => {
|
|
14148
|
+
presence?.stop();
|
|
13680
14149
|
try {
|
|
13681
14150
|
await server.close();
|
|
13682
14151
|
} finally {
|
|
13683
|
-
|
|
14152
|
+
try {
|
|
14153
|
+
await projects.close();
|
|
14154
|
+
} finally {
|
|
14155
|
+
await session.close();
|
|
14156
|
+
}
|
|
13684
14157
|
}
|
|
13685
14158
|
}
|
|
13686
14159
|
};
|
|
13687
14160
|
}
|
|
13688
|
-
/**
|
|
13689
|
-
function
|
|
14161
|
+
/** Whether neither a flag nor the environment says which board to open. */
|
|
14162
|
+
function namesNoBoard(options) {
|
|
14163
|
+
return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
|
|
14164
|
+
}
|
|
14165
|
+
/**
|
|
14166
|
+
* Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
|
|
14167
|
+
* stops listening for both signals whichever it was.
|
|
14168
|
+
*/
|
|
14169
|
+
function waitForStop(idle) {
|
|
13690
14170
|
return new Promise((resolve) => {
|
|
13691
|
-
const
|
|
13692
|
-
process.off("SIGINT",
|
|
13693
|
-
process.off("SIGTERM",
|
|
13694
|
-
resolve();
|
|
14171
|
+
const finish = (why) => {
|
|
14172
|
+
process.off("SIGINT", onSignal);
|
|
14173
|
+
process.off("SIGTERM", onSignal);
|
|
14174
|
+
resolve(why);
|
|
13695
14175
|
};
|
|
13696
|
-
|
|
13697
|
-
process.on("
|
|
14176
|
+
const onSignal = () => finish("interrupt");
|
|
14177
|
+
process.on("SIGINT", onSignal);
|
|
14178
|
+
process.on("SIGTERM", onSignal);
|
|
14179
|
+
idle.then(() => finish("idle"));
|
|
13698
14180
|
});
|
|
13699
14181
|
}
|
|
13700
14182
|
function collect(value, previous) {
|
|
@@ -14018,7 +14500,7 @@ const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
|
14018
14500
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
14019
14501
|
kanbo_card_list: { kanbo: "kanbo card list" },
|
|
14020
14502
|
kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
|
|
14021
|
-
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
|
|
14503
|
+
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
|
|
14022
14504
|
kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
|
|
14023
14505
|
kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
|
|
14024
14506
|
kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
|
|
@@ -14169,4 +14651,4 @@ function buildCapabilitiesManifest() {
|
|
|
14169
14651
|
};
|
|
14170
14652
|
}
|
|
14171
14653
|
//#endregion
|
|
14172
|
-
export { COLUMN_TEMPLATES as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, nextIssueIdentity as Ct, projectKanboFieldChange as D, projectKanboColumn as E, readIssuePrefix as Et,
|
|
14654
|
+
export { COLUMN_TEMPLATES as $, projectKanboSprint as A, createBoardOps as B, pageOf as C, nextIssueIdentity as Ct, projectKanboFieldChange as D, projectKanboColumn as E, readIssuePrefix as Et, BINDING_FILE_PATH as F, ENTRY_RULES as G, formatExternalSessionRef as H, ignoreBoardFile as I, evaluateEntryRules as J, ENTRY_RULE_DESCRIPTIONS as K, readBinding as L, renderCapabilitiesMarkdown as M, maskDatabaseUrl as N, projectKanboPullRequest as O, maskDatabaseUrls as P, COLUMN_CATALOGUE as Q, readProjectBinding as R, matchesKanboCardQuery as S, formatIssueId as St, projectKanboCardComment as T, nextIssueOrder as Tt, parseExternalSessionRef as U, cardDisplayTitle as V, pullRequestFactKeys as W, readEntryRules as X, readChecklist as Y, serializeEntryRules as Z, suggestCardKey as _, parseActivityStringArray as _t, BOARD_ERROR_RESPONSES as a, TRACKED_FIELDS as at, DEFAULT_KANBO_COMMENT_LIMIT as b, normalizeCommentAuthorKind as bt, registerKanboTools as c, BOARD_ACTOR as ct, KANBO_MCP_INSTRUCTIONS as d, activityText as dt, COLUMN_TEMPLATE_IDS as et, KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI as f, activityToken as ft, isValidCardKey as g, isEmptyActivityValue as gt, CARD_KEY_PATTERN as h, formatPlainActivityValue as ht, KANBO_TOOL_NAMES as i, normalizeStatusName as it, projectKanboSubCard as j, projectKanboRun as k, KANBO_TOOLS as l, currentUnixSeconds as lt, registerKanboCapabilitiesResources as m, formatActivityIssueReference as mt, buildCapabilitiesManifest as n, runBoardWrite as nt, KANBO_MCP_SERVER_NAME as o, diffFieldChanges as ot, KANBO_CAPABILITIES_RESOURCE_URI as p, commentAuthorReference as pt, UNMET_ENTRY_RULE_REASONS as q, KANBO_TOOL_CLI_EQUIVALENTS as r, DEFAULT_STATUSES as rt, createKanboMcpServer as s, previewComment as st, CAPABILITIES_MANIFEST_VERSION as t, readChangeSeq as tt, buildKanboTools as u, activityKindOrder as ut, DEFAULT_BOARD_FILE_PATH as v, readSystemCommentKind as vt, projectKanboCard as w, nextIssueNumber as wt, KANBO_CARD_INCLUDES as x, toIssueView as xt, DEFAULT_KANBO_CARD_INCLUDES as y, isOutsideWriter as yt, writeBinding as z };
|