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.
@@ -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
- /** The actor every board write from this tool is filed under. */
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
- if (!await store.issues.findInWorkspace(workspaceId, parentIssueId)) throw new BoardError("issue_parent_not_found", {
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
- return target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
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: normalizeCommentAuthorKind(comment.authorKind),
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 labels").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)).action(async (reference, options) => {
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. A command that names none of
5725
- * them is refused rather than obeyed: an empty update still bumps the board
5726
- * version, and every reader would be told the board changed when it did not.
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 or --execution-mode.");
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.3.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.3.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: "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.",
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: its title, what it is about, how urgent it is, how it is labelled. */
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: "Change what a card says: its title, its description, its priority, its labels or where its work happens.",
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
- run: async (transport, { card, ...patch }) => {
11093
- if (Object.keys(patch).length === 0) throw new Error("Nothing to update. Pass title, description, priority, labels or executionMode.");
11094
- return await transport.cardUpdate({
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: "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.",
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
- return await project(await ops.updateCard(existing.id, patch, actor));
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: createCliActor()
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`. An agent and a provider target are rows of whatever app launched
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)) return {
12680
- kind: "system",
12681
- id: provenance.authorId,
12682
- displayName: provenance.authorId,
12683
- avatarUrl: null,
12684
- label: "External"
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
- function createIssueRoutes(session) {
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.writer));
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
- const matched = matchRoute(routes, request.method ?? "GET", url.pathname);
13438
- if (!matched) throw new ServeError(404, "not_found", "Not Found");
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
- if (matched.route.write) await options.session.assertWritable();
13446
- return await matched.route.run({
13447
- params: matched.params,
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 waitForInterrupt();
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 and ends the process; the home menu waits for
13636
- * Ctrl-C and comes back to its menu.
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: createCliActor(),
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
- output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
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
- await session.close();
14152
+ try {
14153
+ await projects.close();
14154
+ } finally {
14155
+ await session.close();
14156
+ }
13684
14157
  }
13685
14158
  }
13686
14159
  };
13687
14160
  }
13688
- /** Settles on the first Ctrl-C (SIGINT) or SIGTERM, and stops listening for both. */
13689
- function waitForInterrupt() {
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 stop = () => {
13692
- process.off("SIGINT", stop);
13693
- process.off("SIGTERM", stop);
13694
- resolve();
14171
+ const finish = (why) => {
14172
+ process.off("SIGINT", onSignal);
14173
+ process.off("SIGTERM", onSignal);
14174
+ resolve(why);
13695
14175
  };
13696
- process.on("SIGINT", stop);
13697
- process.on("SIGTERM", stop);
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, readBinding as F, ENTRY_RULES as G, formatExternalSessionRef as H, readProjectBinding as I, evaluateEntryRules as J, ENTRY_RULE_DESCRIPTIONS as K, writeBinding as L, renderCapabilitiesMarkdown as M, BINDING_FILE_PATH as N, projectKanboPullRequest as O, ignoreBoardFile as P, COLUMN_CATALOGUE as Q, maskDatabaseUrl 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, maskDatabaseUrls as z };
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 };