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.
@@ -77,22 +77,26 @@ const AGENT_SHELL_MARKERS = [
77
77
  {
78
78
  variable: "CLAUDECODE",
79
79
  matches: (value) => value === "1",
80
- label: "CLAUDECODE=1"
80
+ label: "CLAUDECODE=1",
81
+ tool: "claude"
81
82
  },
82
83
  {
83
84
  variable: "GEMINI_CLI",
84
85
  matches: (value) => value === "1",
85
- label: "GEMINI_CLI=1"
86
+ label: "GEMINI_CLI=1",
87
+ tool: "gemini"
86
88
  },
87
89
  {
88
90
  variable: "CURSOR_AGENT",
89
91
  matches: (value) => value !== "",
90
- label: "CURSOR_AGENT"
92
+ label: "CURSOR_AGENT",
93
+ tool: "cursor"
91
94
  },
92
95
  {
93
96
  variable: "CODEX_THREAD_ID",
94
97
  matches: (value) => value !== "",
95
- label: "CODEX_THREAD_ID"
98
+ label: "CODEX_THREAD_ID",
99
+ tool: "codex"
96
100
  }
97
101
  ];
98
102
  /**
@@ -110,6 +114,15 @@ function readAgentShellMarker(env = process.env) {
110
114
  return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
111
115
  }
112
116
  /**
117
+ * The name of the agent tool this shell belongs to (`claude`, `gemini`,
118
+ * `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
119
+ * `null` for a person's shell. Same precedence as `readAgentShellMarker`.
120
+ */
121
+ function readAgentTool(env = process.env) {
122
+ if (readAgentShellMarker(env) === null) return null;
123
+ return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
124
+ }
125
+ /**
113
126
  * Every exit code this tool promises, and what it means — the capabilities
114
127
  * manifest's `exitCodes` reads this rather than naming the codes again. `0` and
115
128
  * `1` are not declared above: they need no name of their own, success and "I
@@ -305,11 +318,15 @@ const HUMAN_ONLY_MESSAGES = {
305
318
  "run clear-session": describeRunSessionRefRefusal,
306
319
  "run attach-session --replace": describeRunSessionRefRefusal
307
320
  };
308
- /** The actor every board write from this tool is filed under. */
321
+ /**
322
+ * The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
323
+ * else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
324
+ * operating system's user.
325
+ */
309
326
  function createCliActor() {
310
327
  return {
311
328
  kind: "external",
312
- id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
329
+ id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
313
330
  name: (0, node_os.hostname)()
314
331
  };
315
332
  }
@@ -347,6 +364,14 @@ function createPlacementActor() {
347
364
  placesForPerson: true
348
365
  };
349
366
  }
367
+ /** 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. */
368
+ function createServeWriter() {
369
+ return {
370
+ kind: "external",
371
+ id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
372
+ name: (0, node_os.hostname)()
373
+ };
374
+ }
350
375
  /**
351
376
  * The person who owns this shell, as the board files a person. Only for a
352
377
  * caller that has already asked `isAgentShell` — `requireHumanActor` for a
@@ -2836,12 +2861,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
2836
2861
  issueId,
2837
2862
  parentIssueId
2838
2863
  });
2839
- if (!await store.issues.findInWorkspace(workspaceId, parentIssueId)) throw new require_migrations_path.BoardError("issue_parent_not_found", {
2864
+ const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
2865
+ if (!parent) throw new require_migrations_path.BoardError("issue_parent_not_found", {
2840
2866
  workspaceId,
2841
2867
  parentIssueId
2842
2868
  });
2869
+ if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new require_migrations_path.BoardError("issue_parent_cycle", {
2870
+ issueId,
2871
+ parentIssueId
2872
+ });
2843
2873
  return parentIssueId;
2844
2874
  }
2875
+ /**
2876
+ * Is the card somewhere under `ancestorId` — its parent, its parent's parent,
2877
+ * and so on up? A card put under one of its own sub-cards would make a loop no
2878
+ * reader of the board can walk to the top of. The cards already seen end the
2879
+ * walk, so a loop the data holds from before cannot hang it.
2880
+ */
2881
+ async function isUnder(store, card, ancestorId) {
2882
+ const seen = /* @__PURE__ */ new Set();
2883
+ let id = card.parentIssueId;
2884
+ while (id !== null && !seen.has(id)) {
2885
+ if (id === ancestorId) return true;
2886
+ seen.add(id);
2887
+ id = (await store.issues.findById(id))?.parentIssueId ?? null;
2888
+ }
2889
+ return false;
2890
+ }
2845
2891
  /** Is the card's own number already taken by another card in the target workspace? */
2846
2892
  async function numberTakenByAnother(store, workspaceId, card) {
2847
2893
  const holder = await store.issues.findByNumber(workspaceId, card.number);
@@ -3710,62 +3756,6 @@ function createBoardOps(store) {
3710
3756
  };
3711
3757
  }
3712
3758
  //#endregion
3713
- //#region src/domain/database-url.ts
3714
- /**
3715
- * A board's connection string, as it may be said out loud.
3716
- *
3717
- * Everything a person needs to recognise which board they are talking to is
3718
- * the host and the database name. The password is the one part that must never
3719
- * reach a terminal, a log, a `--json` result or a tool's answer — and it is not
3720
- * the only part: a connection string carries options in its query, and a
3721
- * `?password=…` there is as much a secret as the one in front of the host.
3722
- *
3723
- * It is a fact about the value rather than about the command line, which is why
3724
- * it lives here: the MCP tools hand back messages they did not write either,
3725
- * and they must not be made to carry the board tool's own runtime to say them
3726
- * safely.
3727
- */
3728
- /** What a connection string is called when there is nothing safe to print of it. */
3729
- const MASKED_UNREADABLE_URL = "postgres://***";
3730
- /** What is printed in place of a password, and of a query that may hide one. */
3731
- const MASK = "***";
3732
- /**
3733
- * A connection string inside a longer line. Anything but whitespace and the
3734
- * quotes a message may wrap it in belongs to the URL — a password is allowed
3735
- * every character a URL is, so the match has to be greedy to be safe.
3736
- */
3737
- const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
3738
- /**
3739
- * The connection string with everything secret taken out of it.
3740
- *
3741
- * A string this cannot take apart is not examined to find out why — it is
3742
- * replaced entirely, because whatever is wrong with it, the secret is still
3743
- * somewhere inside. A missing scheme counts as that: `user:secret@host/db`
3744
- * parses, as a `user:` URL whose whole password is in the path, and printing it
3745
- * back would say the secret out loud.
3746
- */
3747
- function maskDatabaseUrl(url) {
3748
- try {
3749
- const parsed = new URL(url);
3750
- if (!parsed.host) return MASKED_UNREADABLE_URL;
3751
- if (parsed.password) parsed.password = MASK;
3752
- if (parsed.search) parsed.search = MASK;
3753
- return parsed.toString();
3754
- } catch {
3755
- return MASKED_UNREADABLE_URL;
3756
- }
3757
- }
3758
- /**
3759
- * Every connection string inside a line of text, masked.
3760
- *
3761
- * A message this package did not write — a driver's, a migrator's — may carry
3762
- * the whole connection string in the middle of a sentence, and it reaches the
3763
- * same terminal, or the same agent, as everything else.
3764
- */
3765
- function maskDatabaseUrls(text) {
3766
- return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
3767
- }
3768
- //#endregion
3769
3759
  //#region src/cli/setup/fs-retry.ts
3770
3760
  /**
3771
3761
  * A file operation tried again while something else holds the file.
@@ -4045,6 +4035,62 @@ function ignoreBoardFile(projectDir, absoluteDbPath) {
4045
4035
  (0, node_fs.writeFileSync)(path, kept ? `${kept}\n${missing.join("\n")}\n` : `${missing.join("\n")}\n`);
4046
4036
  }
4047
4037
  //#endregion
4038
+ //#region src/domain/database-url.ts
4039
+ /**
4040
+ * A board's connection string, as it may be said out loud.
4041
+ *
4042
+ * Everything a person needs to recognise which board they are talking to is
4043
+ * the host and the database name. The password is the one part that must never
4044
+ * reach a terminal, a log, a `--json` result or a tool's answer — and it is not
4045
+ * the only part: a connection string carries options in its query, and a
4046
+ * `?password=…` there is as much a secret as the one in front of the host.
4047
+ *
4048
+ * It is a fact about the value rather than about the command line, which is why
4049
+ * it lives here: the MCP tools hand back messages they did not write either,
4050
+ * and they must not be made to carry the board tool's own runtime to say them
4051
+ * safely.
4052
+ */
4053
+ /** What a connection string is called when there is nothing safe to print of it. */
4054
+ const MASKED_UNREADABLE_URL = "postgres://***";
4055
+ /** What is printed in place of a password, and of a query that may hide one. */
4056
+ const MASK = "***";
4057
+ /**
4058
+ * A connection string inside a longer line. Anything but whitespace and the
4059
+ * quotes a message may wrap it in belongs to the URL — a password is allowed
4060
+ * every character a URL is, so the match has to be greedy to be safe.
4061
+ */
4062
+ const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
4063
+ /**
4064
+ * The connection string with everything secret taken out of it.
4065
+ *
4066
+ * A string this cannot take apart is not examined to find out why — it is
4067
+ * replaced entirely, because whatever is wrong with it, the secret is still
4068
+ * somewhere inside. A missing scheme counts as that: `user:secret@host/db`
4069
+ * parses, as a `user:` URL whose whole password is in the path, and printing it
4070
+ * back would say the secret out loud.
4071
+ */
4072
+ function maskDatabaseUrl(url) {
4073
+ try {
4074
+ const parsed = new URL(url);
4075
+ if (!parsed.host) return MASKED_UNREADABLE_URL;
4076
+ if (parsed.password) parsed.password = MASK;
4077
+ if (parsed.search) parsed.search = MASK;
4078
+ return parsed.toString();
4079
+ } catch {
4080
+ return MASKED_UNREADABLE_URL;
4081
+ }
4082
+ }
4083
+ /**
4084
+ * Every connection string inside a line of text, masked.
4085
+ *
4086
+ * A message this package did not write — a driver's, a migrator's — may carry
4087
+ * the whole connection string in the middle of a sentence, and it reaches the
4088
+ * same terminal, or the same agent, as everything else.
4089
+ */
4090
+ function maskDatabaseUrls(text) {
4091
+ return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
4092
+ }
4093
+ //#endregion
4048
4094
  //#region src/cli/db-target.ts
4049
4095
  /**
4050
4096
  * Which board the command works on.
@@ -4293,6 +4339,111 @@ async function openWithPostgresJs(url) {
4293
4339
  };
4294
4340
  }
4295
4341
  //#endregion
4342
+ //#region src/cli/project-registry.ts
4343
+ /**
4344
+ * Which project folders kanbo has opened a board in.
4345
+ *
4346
+ * Every project keeps its board in its own folder, so nothing else knows which
4347
+ * other projects exist. This list, in the user's home, is what lets one board
4348
+ * page offer them all. It holds a folder and a time — never a connection
4349
+ * string, never board data — and deleting it is safe: it fills again as the
4350
+ * projects are used.
4351
+ */
4352
+ /** The file's name inside kanbo's per-user folder. */
4353
+ const REGISTRY_FILE_NAME = "projects.json";
4354
+ /** The file is private to the user, as a binding is. */
4355
+ const REGISTRY_FILE_MODE = 384;
4356
+ /** The folder holding it is private to the user too. */
4357
+ const REGISTRY_DIRECTORY_MODE = 448;
4358
+ /**
4359
+ * A project noted within this many seconds is not noted again, so an ordinary
4360
+ * command does not rewrite the file: a day is fresh enough for "recently used".
4361
+ */
4362
+ const RECENTLY_NOTED_SECONDS = 86400;
4363
+ const RegisteredProjectSchema = zod.z.object({
4364
+ path: zod.z.string().min(1),
4365
+ lastSeenAt: zod.z.number().int().nonnegative()
4366
+ });
4367
+ const RegistrySchema = zod.z.object({
4368
+ schemaVersion: zod.z.literal(1),
4369
+ projects: zod.z.array(RegisteredProjectSchema)
4370
+ });
4371
+ /** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
4372
+ function projectRegistryPath(env = process.env) {
4373
+ const home = env.KANBO_HOME?.trim();
4374
+ return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
4375
+ }
4376
+ /** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
4377
+ function readProjectRegistry(path = projectRegistryPath()) {
4378
+ return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
4379
+ }
4380
+ /**
4381
+ * Note that `projectDir` was just used. Writes only when the folder is new or
4382
+ * was last noted more than a day ago, so ordinary commands do not rewrite the file.
4383
+ * Drops entries whose `.kanbo/binding.json` is gone.
4384
+ */
4385
+ function rememberProject(projectDir, options = {}) {
4386
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
4387
+ const path = options.path ?? projectRegistryPath();
4388
+ const canonical = canonicalPath$1(projectDir);
4389
+ const entries = readEntries(path);
4390
+ const known = entries.find((entry) => entry.path === canonical);
4391
+ if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
4392
+ const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
4393
+ kept.push({
4394
+ path: canonical,
4395
+ lastSeenAt: now
4396
+ });
4397
+ writeEntries(path, kept);
4398
+ }
4399
+ /**
4400
+ * `rememberProject`, for the places that must not fail because of it: a home
4401
+ * directory that cannot be written is not a reason for `kanbo card list` to fail.
4402
+ * With `KANBO_DEBUG=1` the reason is printed on stderr.
4403
+ */
4404
+ function rememberProjectQuietly(projectDir) {
4405
+ const path = projectRegistryPath();
4406
+ try {
4407
+ rememberProject(projectDir, { path });
4408
+ } catch (error) {
4409
+ if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
4410
+ }
4411
+ }
4412
+ function canonicalPath$1(projectDir) {
4413
+ const absolute = (0, node_path.resolve)(projectDir);
4414
+ try {
4415
+ return (0, node_fs.realpathSync)(absolute);
4416
+ } catch {
4417
+ return absolute;
4418
+ }
4419
+ }
4420
+ function readEntries(path) {
4421
+ try {
4422
+ const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
4423
+ return parsed.success ? parsed.data.projects : [];
4424
+ } catch {
4425
+ return [];
4426
+ }
4427
+ }
4428
+ function writeEntries(path, projects) {
4429
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
4430
+ recursive: true,
4431
+ mode: REGISTRY_DIRECTORY_MODE
4432
+ });
4433
+ const temporaryPath = `${path}.${process.pid}.tmp`;
4434
+ try {
4435
+ (0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
4436
+ schemaVersion: 1,
4437
+ projects
4438
+ }), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
4439
+ (0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
4440
+ renameWithRetry(temporaryPath, path);
4441
+ } catch (error) {
4442
+ (0, node_fs.rmSync)(temporaryPath, { force: true });
4443
+ throw error;
4444
+ }
4445
+ }
4446
+ //#endregion
4296
4447
  //#region src/cli/schema-guard.ts
4297
4448
  const SCHEMA_OUTDATED_MESSAGE = "The app's database is older than this kanbo. Let the app it belongs to update it first.";
4298
4449
  /** The same, for a board file this package created and migrates itself. */
@@ -4538,15 +4689,17 @@ function withBoardOptions(command) {
4538
4689
  * as a client is talking to it. A failure on the way out closes the board first
4539
4690
  * — a caller that never received a session has nothing to close.
4540
4691
  */
4541
- async function openBoardSession(options, mode) {
4542
- const cwd = process.cwd();
4692
+ async function openBoardSession(options, mode, cwd = process.cwd()) {
4543
4693
  const target = resolveDbTarget({
4544
4694
  explicitPath: options.db,
4545
4695
  explicitUrl: options.databaseUrl,
4546
4696
  asAgent: options.asAgent,
4547
4697
  cwd
4548
4698
  });
4549
- return target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
4699
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
4700
+ const found = findBinding(cwd);
4701
+ if (found) rememberProjectQuietly(found.projectDir);
4702
+ return session;
4550
4703
  }
4551
4704
  async function openFileSession(target, options, mode, cwd) {
4552
4705
  const board = await require_migrate.openBoardDatabase(target.path);
@@ -5431,9 +5584,14 @@ function pageOf(cards, input) {
5431
5584
  }
5432
5585
  /** A comment as a card read lists it, from the row either transport holds. */
5433
5586
  function projectKanboCardComment(comment) {
5587
+ const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
5588
+ const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
5589
+ const named = author !== "system" || decision;
5590
+ const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
5434
5591
  return {
5435
5592
  id: comment.id,
5436
- author: normalizeCommentAuthorKind(comment.authorKind),
5593
+ author,
5594
+ by: named || outside ? comment.authorId ?? author : "kanbo",
5437
5595
  content: comment.content,
5438
5596
  createdAt: comment.createdAt
5439
5597
  };
@@ -5504,7 +5662,7 @@ function describeCardExtras(extras) {
5504
5662
  })))].join("\n"));
5505
5663
  if (extras.comments && extras.comments.length > 0) {
5506
5664
  const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
5507
- sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
5665
+ sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
5508
5666
  }
5509
5667
  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"));
5510
5668
  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"));
@@ -5602,10 +5760,11 @@ function registerCardCommands(program) {
5602
5760
  }, createPlacementActor()));
5603
5761
  });
5604
5762
  });
5605
- 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) => {
5763
+ 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) => {
5606
5764
  await runBoardCommand(options, "write", async (session) => {
5607
5765
  const input = readUpdateInput(options);
5608
5766
  const existing = await requireCard(session, reference);
5767
+ if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
5609
5768
  return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
5610
5769
  });
5611
5770
  });
@@ -5746,9 +5905,10 @@ async function presentCard(session, card) {
5746
5905
  };
5747
5906
  }
5748
5907
  /**
5749
- * The fields `card update` was asked to change. A command that names none of
5750
- * them is refused rather than obeyed: an empty update still bumps the board
5751
- * version, and every reader would be told the board changed when it did not.
5908
+ * The fields `card update` was asked to change — all but the parent, a card
5909
+ * of the board, looked up there once the card itself is. A command that names
5910
+ * none of them is refused rather than obeyed: an empty update still bumps the
5911
+ * board version, and every reader would be told the board changed when it did not.
5752
5912
  */
5753
5913
  function readUpdateInput(options) {
5754
5914
  const input = {};
@@ -5757,7 +5917,7 @@ function readUpdateInput(options) {
5757
5917
  if (options.priority !== void 0) input.priority = options.priority;
5758
5918
  if (options.labels !== void 0) input.labels = options.labels;
5759
5919
  if (options.executionMode !== void 0) input.executionMode = options.executionMode;
5760
- if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels or --execution-mode.");
5920
+ 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.");
5761
5921
  return input;
5762
5922
  }
5763
5923
  function parsePriority(value) {
@@ -6027,6 +6187,7 @@ const BOARD_ERROR_TEXTS = {
6027
6187
  text: `No card "${named(details.issueId)}" here.`,
6028
6188
  next: "kanbo card list"
6029
6189
  }),
6190
+ issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
6030
6191
  issue_parent_not_found: (details) => ({
6031
6192
  text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
6032
6193
  next: "kanbo card list"
@@ -6599,7 +6760,7 @@ function readRequestedRules(options) {
6599
6760
  * `package.version`. A build carries it as a literal; running from source
6600
6761
  * (tests, `tsx`) reads the package's own `package.json` instead.
6601
6762
  */
6602
- const KANBO_PACKAGE_VERSION = "0.3.0";
6763
+ const KANBO_PACKAGE_VERSION = "0.4.0";
6603
6764
  //#endregion
6604
6765
  //#region src/cli/setup/paths.ts
6605
6766
  /**
@@ -6995,6 +7156,7 @@ const COMMAND_SHEET = [
6995
7156
  "- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
6996
7157
  "- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
6997
7158
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
7159
+ "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
6998
7160
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
6999
7161
  "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
7000
7162
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
@@ -8080,7 +8242,7 @@ function startUpdateCheck(deps = {}) {
8080
8242
  setTimeout: globalThis.setTimeout,
8081
8243
  clearTimeout: globalThis.clearTimeout
8082
8244
  };
8083
- const current = deps.currentVersion ?? "0.3.0";
8245
+ const current = deps.currentVersion ?? "0.4.0";
8084
8246
  const controller = new AbortController();
8085
8247
  let found = null;
8086
8248
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -9317,6 +9479,7 @@ async function applyInitPlan(plan) {
9317
9479
  }
9318
9480
  if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
9319
9481
  const bindingPath = writeBinding(plan.projectDir, plan.binding);
9482
+ rememberProjectQuietly(plan.projectDir);
9320
9483
  const files = plan.fileChanges.map(({ kind, change }) => ({
9321
9484
  kind,
9322
9485
  ...applyFileChange(change)
@@ -10064,7 +10227,7 @@ async function planAgents(projectDir, agents, options) {
10064
10227
  }
10065
10228
  /** The board this project is bound to, and the workspace on it. */
10066
10229
  async function resolveBoard(projectDir, options) {
10067
- if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
10230
+ if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
10068
10231
  const target = resolveInitTarget(projectDir, options);
10069
10232
  if (target.kind === "postgres") return {
10070
10233
  target,
@@ -10083,7 +10246,7 @@ async function resolveBoard(projectDir, options) {
10083
10246
  * does — a person on a machine with nothing else installed gets a board, not a
10084
10247
  * refusal that names a flag.
10085
10248
  */
10086
- function namesNoBoard(projectDir, options) {
10249
+ function namesNoBoard$1(projectDir, options) {
10087
10250
  return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
10088
10251
  }
10089
10252
  /** The workspace a host database — or the board file this project is already bound to — says this project is. */
@@ -10516,6 +10679,7 @@ const KANBO_MCP_INSTRUCTIONS = [
10516
10679
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
10517
10680
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
10518
10681
  `- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
10682
+ `- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
10519
10683
  `- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
10520
10684
  `- ${PERSON_ONLY_RULE}`,
10521
10685
  `- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
@@ -10895,61 +11059,6 @@ const cardCommentTool = defineKanboTool({
10895
11059
  },
10896
11060
  run: async (transport, input) => await transport.cardComment(input)
10897
11061
  });
10898
- //#endregion
10899
- //#region src/mcp/tools/card-create.ts
10900
- /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
10901
- const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
10902
- /** A new card, or a sub-card of the one being worked on. */
10903
- const cardCreateTool = defineKanboTool({
10904
- name: "kanbo_card_create",
10905
- title: "Create a card",
10906
- description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
10907
- inputSchema: {
10908
- title: zod.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."),
10909
- description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
10910
- column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
10911
- parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
10912
- executionMode: executionModeArgument.optional()
10913
- },
10914
- example: {
10915
- description: "Add slugify(text) with a test",
10916
- parent: "TST-1"
10917
- },
10918
- run: async (transport, input) => await transport.cardCreate(input)
10919
- });
10920
- //#endregion
10921
- //#region src/mcp/tools/card-get.ts
10922
- /** One card in full, with what an agent reads next to it. */
10923
- const cardGetTool = defineKanboTool({
10924
- name: "kanbo_card_get",
10925
- title: "Read a card",
10926
- 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"].`,
10927
- inputSchema: {
10928
- card: cardArgument,
10929
- include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
10930
- commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
10931
- },
10932
- example: { card: "TST-5" },
10933
- run: async (transport, input) => await transport.cardGet(input)
10934
- });
10935
- //#endregion
10936
- //#region src/mcp/tools/card-link-pr.ts
10937
- /** A pull request the card's work went into, named on the card so its facts reach it. */
10938
- const cardLinkPrTool = defineKanboTool({
10939
- name: "kanbo_card_link_pr",
10940
- title: "Link a pull request to a card",
10941
- 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.",
10942
- inputSchema: {
10943
- card: cardArgument,
10944
- url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
10945
- },
10946
- needs: { url: "the pull request" },
10947
- example: {
10948
- card: "TST-5",
10949
- url: "https://github.com/owner/repo/pull/12"
10950
- },
10951
- run: async (transport, input) => await transport.cardLinkPullRequest(input)
10952
- });
10953
11062
  /** The `detail` and `fields` arguments the list tools share. */
10954
11063
  const cardListViewArguments = {
10955
11064
  detail: zod.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."),
@@ -10988,6 +11097,20 @@ function compactKanboCard(card) {
10988
11097
  ...card.lastComment ? { lastComment: card.lastComment } : {}
10989
11098
  };
10990
11099
  }
11100
+ /** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
11101
+ const writtenCardDetailArgument = zod.z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
11102
+ /** What each tool that writes a card says about its answer, in its description. */
11103
+ const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
11104
+ /**
11105
+ * The card a write hands back, as the tool answers with it: compact and on one
11106
+ * line, as a list prints it. An agent writes its card at every step — a status
11107
+ * line, a move, an update — and reads each answer again on every later turn,
11108
+ * so a whole card, description included, would cost it again and again.
11109
+ * `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
11110
+ */
11111
+ function formatWrittenKanboCard(card, detail) {
11112
+ return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
11113
+ }
10991
11114
  function viewCard(card, view) {
10992
11115
  if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
10993
11116
  return view.detail === "full" ? card : compactKanboCard(card);
@@ -11011,6 +11134,62 @@ function formatKanboCardPage(page, view, returned) {
11011
11134
  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)}`;
11012
11135
  return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
11013
11136
  }
11137
+ //#endregion
11138
+ //#region src/mcp/tools/card-create.ts
11139
+ /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
11140
+ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
11141
+ /** A new card, or a sub-card of the one being worked on. */
11142
+ const cardCreateTool = defineKanboTool({
11143
+ name: "kanbo_card_create",
11144
+ title: "Create a card",
11145
+ 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.`,
11146
+ inputSchema: {
11147
+ title: zod.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."),
11148
+ description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
11149
+ column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
11150
+ parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
11151
+ executionMode: executionModeArgument.optional(),
11152
+ detail: writtenCardDetailArgument
11153
+ },
11154
+ example: {
11155
+ description: "Add slugify(text) with a test",
11156
+ parent: "TST-1"
11157
+ },
11158
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
11159
+ });
11160
+ //#endregion
11161
+ //#region src/mcp/tools/card-get.ts
11162
+ /** One card in full, with what an agent reads next to it. */
11163
+ const cardGetTool = defineKanboTool({
11164
+ name: "kanbo_card_get",
11165
+ title: "Read a card",
11166
+ 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"].`,
11167
+ inputSchema: {
11168
+ card: cardArgument,
11169
+ include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
11170
+ commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
11171
+ },
11172
+ example: { card: "TST-5" },
11173
+ run: async (transport, input) => await transport.cardGet(input)
11174
+ });
11175
+ //#endregion
11176
+ //#region src/mcp/tools/card-link-pr.ts
11177
+ /** A pull request the card's work went into, named on the card so its facts reach it. */
11178
+ const cardLinkPrTool = defineKanboTool({
11179
+ name: "kanbo_card_link_pr",
11180
+ title: "Link a pull request to a card",
11181
+ 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.",
11182
+ inputSchema: {
11183
+ card: cardArgument,
11184
+ url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
11185
+ },
11186
+ needs: { url: "the pull request" },
11187
+ example: {
11188
+ card: "TST-5",
11189
+ url: "https://github.com/owner/repo/pull/12"
11190
+ },
11191
+ run: async (transport, input) => await transport.cardLinkPullRequest(input)
11192
+ });
11014
11193
  /** The cards on the board a question picks, one page of them. */
11015
11194
  const cardListTool = defineKanboTool({
11016
11195
  name: "kanbo_card_list",
@@ -11065,10 +11244,11 @@ function readMoment(value) {
11065
11244
  const cardMoveTool = defineKanboTool({
11066
11245
  name: "kanbo_card_move",
11067
11246
  title: "Move a card",
11068
- 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.",
11247
+ 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}`,
11069
11248
  inputSchema: {
11070
11249
  card: cardArgument,
11071
- column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
11250
+ column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
11251
+ detail: writtenCardDetailArgument
11072
11252
  },
11073
11253
  aliases: { column: ["to"] },
11074
11254
  needs: { column: "the column slug, e.g. in_progress" },
@@ -11076,7 +11256,7 @@ const cardMoveTool = defineKanboTool({
11076
11256
  card: "TST-5",
11077
11257
  column: "in_progress"
11078
11258
  },
11079
- run: async (transport, input) => await transport.cardMove(input)
11259
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
11080
11260
  });
11081
11261
  //#endregion
11082
11262
  //#region src/mcp/tools/card-pull-requests.ts
@@ -11091,11 +11271,11 @@ const cardPullRequestsTool = defineKanboTool({
11091
11271
  });
11092
11272
  //#endregion
11093
11273
  //#region src/mcp/tools/card-update.ts
11094
- /** What a card says: its title, what it is about, how urgent it is, how it is labelled. */
11274
+ /** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
11095
11275
  const cardUpdateTool = defineKanboTool({
11096
11276
  name: "kanbo_card_update",
11097
11277
  title: "Update a card",
11098
- description: "Change what a card says: its title, its description, its priority, its labels or where its work happens.",
11278
+ 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}`,
11099
11279
  inputSchema: {
11100
11280
  card: cardArgument,
11101
11281
  title: zod.z.string().min(1).optional().describe("What the card is called."),
@@ -11108,18 +11288,26 @@ const cardUpdateTool = defineKanboTool({
11108
11288
  "urgent"
11109
11289
  ]).optional(),
11110
11290
  labels: zod.z.array(zod.z.string()).optional().describe("The labels the card carries, replacing the ones it has."),
11111
- executionMode: executionModeArgument.optional()
11291
+ executionMode: executionModeArgument.optional(),
11292
+ 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."),
11293
+ detail: writtenCardDetailArgument
11112
11294
  },
11113
11295
  example: {
11114
11296
  card: "TST-5",
11115
11297
  title: "Add slugify"
11116
11298
  },
11117
- run: async (transport, { card, ...patch }) => {
11118
- if (Object.keys(patch).length === 0) throw new Error("Nothing to update. Pass title, description, priority, labels or executionMode.");
11119
- return await transport.cardUpdate({
11299
+ prepare: (input) => {
11300
+ const { parent, ...rest } = input;
11301
+ return parent === null ? rest : input;
11302
+ },
11303
+ run: async (transport, { card, detail, parent, ...patch }) => {
11304
+ if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
11305
+ const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
11306
+ return formatWrittenKanboCard(await transport.cardUpdate({
11120
11307
  card,
11121
- ...patch
11122
- });
11308
+ ...patch,
11309
+ ...placement
11310
+ }), detail);
11123
11311
  }
11124
11312
  });
11125
11313
  //#endregion
@@ -11256,10 +11444,11 @@ const KANBO_TOOLS = [
11256
11444
  defineKanboTool({
11257
11445
  name: "kanbo_status_line",
11258
11446
  title: "Set the status line",
11259
- 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.",
11447
+ 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,
11260
11448
  inputSchema: {
11261
11449
  card: cardArgument,
11262
- text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
11450
+ text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
11451
+ detail: writtenCardDetailArgument
11263
11452
  },
11264
11453
  aliases: { text: ["content"] },
11265
11454
  needs: { text: "the status line, one sentence" },
@@ -11267,22 +11456,23 @@ const KANBO_TOOLS = [
11267
11456
  card: "TST-5",
11268
11457
  text: "Running the tests"
11269
11458
  },
11270
- run: async (transport, input) => await transport.statusLine(input)
11459
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
11271
11460
  }),
11272
11461
  defineKanboTool({
11273
11462
  name: "kanbo_wait_approval",
11274
11463
  title: "Hand the card to a person",
11275
- 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.",
11464
+ 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}`,
11276
11465
  inputSchema: {
11277
11466
  card: cardArgument,
11278
- text: zod.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`.")
11467
+ text: zod.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`."),
11468
+ detail: writtenCardDetailArgument
11279
11469
  },
11280
11470
  aliases: { text: ["content"] },
11281
11471
  example: {
11282
11472
  card: "TST-5",
11283
11473
  text: "Ready for review: slugify added with a test"
11284
11474
  },
11285
- run: async (transport, input) => await transport.waitApproval(input)
11475
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
11286
11476
  }),
11287
11477
  runStartTool,
11288
11478
  runFinishTool
@@ -11483,10 +11673,14 @@ async function createDbTransport(input) {
11483
11673
  executionMode
11484
11674
  }, actor));
11485
11675
  },
11486
- cardUpdate: async ({ card, ...patch }) => {
11676
+ cardUpdate: async ({ card, parent, ...patch }) => {
11487
11677
  await session.assertWritable();
11488
11678
  const existing = await resolveCard(card);
11489
- return await project(await ops.updateCard(existing.id, patch, actor));
11679
+ const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
11680
+ return await project(await ops.updateCard(existing.id, {
11681
+ ...patch,
11682
+ ...placement
11683
+ }, actor));
11490
11684
  },
11491
11685
  cardMove: async ({ card, column }) => {
11492
11686
  await session.assertWritable();
@@ -11640,13 +11834,40 @@ function withUpdateNote(board, peek) {
11640
11834
  }
11641
11835
  //#endregion
11642
11836
  //#region src/cli/commands/mcp.ts
11837
+ /**
11838
+ * The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
11839
+ * else the agent tool, else `agent` until the client says its name (see
11840
+ * `nameActorAfterClient`).
11841
+ */
11842
+ function createMcpActor() {
11843
+ const tool = readAgentTool();
11844
+ const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
11845
+ const actor = createCliActor();
11846
+ if (!namedByShell) actor.id = "agent";
11847
+ return {
11848
+ actor,
11849
+ namedByShell
11850
+ };
11851
+ }
11852
+ /**
11853
+ * Files the session's writes under the client's own name (`Test Client` ->
11854
+ * `test-client`) once it has initialized. `actor` is the object the board
11855
+ * holds, so later writes see the new id.
11856
+ */
11857
+ function nameActorAfterClient(server, actor) {
11858
+ server.server.oninitialized = () => {
11859
+ const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
11860
+ if (id) actor.id = id;
11861
+ };
11862
+ }
11643
11863
  function registerMcpCommand(program) {
11644
11864
  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) => {
11865
+ const { actor, namedByShell } = createMcpActor();
11645
11866
  const board = await openMcpBoard({
11646
11867
  dbPath: options.db,
11647
11868
  databaseUrl: options.databaseUrl,
11648
11869
  workspaceId: options.workspace,
11649
- actor: createCliActor()
11870
+ actor
11650
11871
  });
11651
11872
  let closing;
11652
11873
  const close = () => closing ??= board.close().catch((error) => {
@@ -11655,6 +11876,7 @@ function registerMcpCommand(program) {
11655
11876
  const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
11656
11877
  const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
11657
11878
  server.server.onclose = () => void close();
11879
+ if (!namedByShell) nameActorAfterClient(server, actor);
11658
11880
  try {
11659
11881
  await server.connect(new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport());
11660
11882
  } catch (error) {
@@ -12299,6 +12521,10 @@ const BOARD_ERROR_RESPONSES = {
12299
12521
  status: 404,
12300
12522
  message: "Issue not found"
12301
12523
  },
12524
+ issue_parent_cycle: {
12525
+ status: 400,
12526
+ message: "Issue cannot be put under one of its own sub-issues"
12527
+ },
12302
12528
  issue_parent_not_found: {
12303
12529
  status: 404,
12304
12530
  message: "Parent issue not found"
@@ -12680,10 +12906,20 @@ function toStatusView(status) {
12680
12906
  entryRules: readEntryRules(status)
12681
12907
  };
12682
12908
  }
12683
- function toCommentView(comment) {
12909
+ function toCommentView(comment, personWriterId = null) {
12910
+ if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
12911
+ ...comment,
12912
+ author: {
12913
+ kind: "user",
12914
+ id: comment.authorId ?? "__self__",
12915
+ displayName: "You",
12916
+ avatarUrl: null,
12917
+ label: null
12918
+ }
12919
+ };
12684
12920
  return {
12685
12921
  ...comment,
12686
- author: resolveActor(commentAuthorReference(comment))
12922
+ author: resolveActor(commentAuthorReference(comment), personWriterId)
12687
12923
  };
12688
12924
  }
12689
12925
  /**
@@ -12691,23 +12927,34 @@ function toCommentView(comment) {
12691
12927
  *
12692
12928
  * For the kinds that need nobody else's tables: an outside writer is shown
12693
12929
  * under the name it gave, the board writing for itself is `kanbo`, and a person
12694
- * is `You`. An agent and a provider target are rows of whatever app launched
12930
+ * is `You`. So is an outside writer that is the person who started this
12931
+ * server (`personWriterId`): their own writes carry the name their shell files
12932
+ * them under. An agent and a provider target are rows of whatever app launched
12695
12933
  * them, which this server cannot read, so they are shown by the id the board
12696
12934
  * holds for them.
12697
12935
  */
12698
- function resolveActor(actor) {
12936
+ function resolveActor(actor, personWriterId) {
12699
12937
  if (actor.kind === "system") {
12700
12938
  const provenance = {
12701
12939
  authorId: actor.id,
12702
12940
  sourceChatSessionId: actor.sourceChatSessionId
12703
12941
  };
12704
- if (isOutsideWriter(provenance)) return {
12705
- kind: "system",
12706
- id: provenance.authorId,
12707
- displayName: provenance.authorId,
12708
- avatarUrl: null,
12709
- label: "External"
12710
- };
12942
+ if (isOutsideWriter(provenance)) {
12943
+ if (personWriterId !== null && provenance.authorId === personWriterId) return {
12944
+ kind: "user",
12945
+ id: provenance.authorId,
12946
+ displayName: "You",
12947
+ avatarUrl: null,
12948
+ label: null
12949
+ };
12950
+ return {
12951
+ kind: "system",
12952
+ id: provenance.authorId,
12953
+ displayName: provenance.authorId,
12954
+ avatarUrl: null,
12955
+ label: "External"
12956
+ };
12957
+ }
12711
12958
  return {
12712
12959
  kind: "system",
12713
12960
  id: null,
@@ -12743,13 +12990,13 @@ function resolveActor(actor) {
12743
12990
  * serves, named by its identity; actors as `resolveActor` reads them; and an
12744
12991
  * agent assignee by its id.
12745
12992
  */
12746
- function createActivityResolver(workspace) {
12993
+ function createActivityResolver(workspace, personWriterId = null) {
12747
12994
  return {
12748
12995
  workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
12749
12996
  id: workspace.id,
12750
12997
  name: workspace.name
12751
12998
  }]]),
12752
- resolveActor: async (actor) => resolveActor(actor),
12999
+ resolveActor: async (actor) => resolveActor(actor, personWriterId),
12753
13000
  resolveAgentName: async (agentId) => agentId
12754
13001
  };
12755
13002
  }
@@ -12770,10 +13017,15 @@ function createActivityResolver(workspace) {
12770
13017
  const OK = { ok: true };
12771
13018
  /** How many cards a search answers with when the caller names no limit. */
12772
13019
  const DEFAULT_SEARCH_LIMIT = 20;
12773
- function createIssueRoutes(session) {
13020
+ /**
13021
+ * `personWriterId` is the id the person who started the server is filed under;
13022
+ * their writes read as `You`, everyone else's by name.
13023
+ */
13024
+ function createIssueRoutes(session, options = {}) {
13025
+ const personWriterId = options.personWriterId ?? null;
12774
13026
  const { ops, store, workspace } = session;
12775
13027
  const workspaceId = workspace.id;
12776
- const activityResolver = createActivityResolver(workspace);
13028
+ const activityResolver = createActivityResolver(workspace, personWriterId);
12777
13029
  /** The served workspace, when that is the one the caller named — or named none. */
12778
13030
  function requireWorkspace(named) {
12779
13031
  if (named !== void 0 && named !== workspaceId) throw new require_migrations_path.BoardError("issue_workspace_not_found", { workspaceId: named });
@@ -13058,6 +13310,15 @@ function createIssueRoutes(session) {
13058
13310
  return updated;
13059
13311
  }
13060
13312
  }),
13313
+ route({
13314
+ method: "GET",
13315
+ path: "/issues/workspace",
13316
+ handle: async () => ({
13317
+ id: workspace.id,
13318
+ identifier: workspace.identifier,
13319
+ name: workspace.name
13320
+ })
13321
+ }),
13061
13322
  route({
13062
13323
  method: "GET",
13063
13324
  path: "/issues/:id",
@@ -13211,7 +13472,7 @@ function createIssueRoutes(session) {
13211
13472
  path: "/issues/:id/comments",
13212
13473
  handle: async ({ params }) => {
13213
13474
  await requireCard(params.id);
13214
- return (await ops.listComments(params.id)).map(toCommentView);
13475
+ return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
13215
13476
  }
13216
13477
  }),
13217
13478
  route({
@@ -13224,7 +13485,7 @@ function createIssueRoutes(session) {
13224
13485
  return toCommentView(await ops.addComment({
13225
13486
  issueId: params.id,
13226
13487
  content: body.content
13227
- }, actors.writer));
13488
+ }, actors.person), personWriterId);
13228
13489
  }
13229
13490
  }),
13230
13491
  route({
@@ -13353,7 +13614,7 @@ function assertServeBinding(host, token) {
13353
13614
  }
13354
13615
  async function startKanboServer(options) {
13355
13616
  assertServeBinding(options.host, options.token);
13356
- const routes = createIssueRoutes(options.session);
13617
+ const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
13357
13618
  const listenHost = stripBrackets(options.host);
13358
13619
  let boundPort = 0;
13359
13620
  const handle = createRequestHandler(routes, options, () => boundPort);
@@ -13399,6 +13660,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
13399
13660
  "HEAD",
13400
13661
  "OPTIONS"
13401
13662
  ]);
13663
+ /** What the board page tells the server about itself. */
13664
+ const PRESENCE_BODY = zod.z.object({
13665
+ tabId: zod.z.string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
13666
+ state: zod.z.enum(["open", "closed"])
13667
+ });
13402
13668
  function createRequestHandler(routes, options, boundPort) {
13403
13669
  const token = options.token || null;
13404
13670
  const corsOrigins = new Set(options.corsOrigins ?? []);
@@ -13459,17 +13725,41 @@ function createRequestHandler(routes, options, boundPort) {
13459
13725
  throw new ServeError(401, "unauthorized", "A valid bearer token is required");
13460
13726
  }
13461
13727
  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");
13462
- const matched = matchRoute(routes, request.method ?? "GET", url.pathname);
13463
- if (!matched) throw new ServeError(404, "not_found", "Not Found");
13728
+ if (url.pathname === "/page/presence" && request.method === "POST") {
13729
+ if (!options.presence) {
13730
+ response.writeHead(204).end();
13731
+ return;
13732
+ }
13733
+ const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
13734
+ if (!parsed.success) throw validationError("body", parsed.error);
13735
+ if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
13736
+ else options.presence.closed(parsed.data.tabId);
13737
+ sendJson(response, 200, { ok: true });
13738
+ return;
13739
+ }
13740
+ const projectPath = readProjectPath(url.pathname);
13741
+ if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
13742
+ sendJson(response, 200, options.projects.list());
13743
+ return;
13744
+ }
13745
+ const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
13746
+ if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
13747
+ if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
13464
13748
  const body = await readJsonBody(request);
13465
13749
  const actors = resolveRequestActors(options.actors, {
13466
13750
  actorHeader: request.headers[ACTOR_HEADER],
13467
13751
  authenticated: token !== null
13468
13752
  });
13469
13753
  const result = queue.then(async () => {
13470
- if (matched.route.write) await options.session.assertWritable();
13471
- return await matched.route.run({
13472
- params: matched.params,
13754
+ const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
13755
+ session: options.session,
13756
+ routes
13757
+ };
13758
+ const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
13759
+ if (!found) throw new ServeError(404, "not_found", "Not Found");
13760
+ if (found.route.write) await target.session.assertWritable();
13761
+ return await found.route.run({
13762
+ params: found.params,
13473
13763
  query: readQuery(url.searchParams),
13474
13764
  body,
13475
13765
  actors
@@ -13495,6 +13785,21 @@ function createRequestHandler(routes, options, boundPort) {
13495
13785
  }
13496
13786
  };
13497
13787
  }
13788
+ /** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
13789
+ function readProjectPath(pathname) {
13790
+ const segments = pathname.split("/").filter(Boolean);
13791
+ if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
13792
+ let projectId;
13793
+ try {
13794
+ projectId = decodeURIComponent(segments[1]);
13795
+ } catch {
13796
+ return null;
13797
+ }
13798
+ return {
13799
+ projectId,
13800
+ rest: `/${segments.slice(2).join("/")}`
13801
+ };
13802
+ }
13498
13803
  function isJsonContentType(value) {
13499
13804
  return value?.split(";")[0].trim().toLowerCase() === "application/json";
13500
13805
  }
@@ -13638,6 +13943,146 @@ function readTextOrNull(path) {
13638
13943
  }
13639
13944
  }
13640
13945
  //#endregion
13946
+ //#region src/serve/page-presence.ts
13947
+ function createPagePresence(options) {
13948
+ const closedGraceMs = options.closedGraceMs ?? 5e3;
13949
+ const staleMs = options.staleMs ?? 12e4;
13950
+ const tickMs = options.tickMs ?? 1e4;
13951
+ const now = options.now ?? Date.now;
13952
+ const tabs = /* @__PURE__ */ new Map();
13953
+ let finished = false;
13954
+ let graceTimer = null;
13955
+ let lastTick = now();
13956
+ function stop() {
13957
+ finished = true;
13958
+ clearInterval(ticker);
13959
+ if (graceTimer) {
13960
+ clearTimeout(graceTimer);
13961
+ graceTimer = null;
13962
+ }
13963
+ }
13964
+ function idle() {
13965
+ if (finished) return;
13966
+ stop();
13967
+ options.onIdle();
13968
+ }
13969
+ const ticker = setInterval(() => {
13970
+ const current = now();
13971
+ const asleep = current - lastTick > 3 * tickMs;
13972
+ lastTick = current;
13973
+ if (asleep) {
13974
+ for (const id of tabs.keys()) tabs.set(id, current);
13975
+ return;
13976
+ }
13977
+ if (tabs.size === 0) return;
13978
+ for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
13979
+ if (tabs.size === 0) idle();
13980
+ }, tickMs);
13981
+ ticker.unref();
13982
+ return {
13983
+ seen: (tabId) => {
13984
+ if (!finished) tabs.set(tabId, now());
13985
+ },
13986
+ closed: (tabId) => {
13987
+ if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
13988
+ if (graceTimer) clearTimeout(graceTimer);
13989
+ graceTimer = setTimeout(() => {
13990
+ graceTimer = null;
13991
+ if (tabs.size === 0) idle();
13992
+ }, closedGraceMs);
13993
+ graceTimer.unref();
13994
+ },
13995
+ stop
13996
+ };
13997
+ }
13998
+ //#endregion
13999
+ //#region src/serve/projects.ts
14000
+ function createServeProjects(input) {
14001
+ const routesOptions = { personWriterId: input.personWriterId ?? null };
14002
+ const opened = /* @__PURE__ */ new Map();
14003
+ let defaultOpened = null;
14004
+ /** Every project that can be offered right now, default first, with the folder each lives in. */
14005
+ function entries() {
14006
+ const defaultDir = input.defaultProject?.dir ?? null;
14007
+ const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
14008
+ const found = [];
14009
+ for (const dir of dirs) {
14010
+ const binding = input.readBinding(dir);
14011
+ if (binding === null && dir !== defaultDir) continue;
14012
+ found.push({
14013
+ view: viewOf(dir, binding, dir === defaultDir),
14014
+ dir
14015
+ });
14016
+ }
14017
+ return found;
14018
+ }
14019
+ return {
14020
+ list: () => entries().map((entry) => entry.view),
14021
+ resolve: async (projectId) => {
14022
+ const entry = entries().find((candidate) => candidate.view.id === projectId);
14023
+ if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
14024
+ if (entry.view.isDefault && input.defaultProject) {
14025
+ defaultOpened ??= {
14026
+ session: input.defaultProject.session,
14027
+ routes: createIssueRoutes(input.defaultProject.session, routesOptions)
14028
+ };
14029
+ return defaultOpened;
14030
+ }
14031
+ const cached = opened.get(projectId);
14032
+ if (cached) return cached;
14033
+ let session;
14034
+ try {
14035
+ session = await input.openSession(entry.dir);
14036
+ } catch (error) {
14037
+ throw new ServeError(503, "project_unavailable", describeFailure(error).message);
14038
+ }
14039
+ const project = {
14040
+ session,
14041
+ routes: createIssueRoutes(session, routesOptions)
14042
+ };
14043
+ opened.set(projectId, project);
14044
+ return project;
14045
+ },
14046
+ close: async () => {
14047
+ const sessions = [...opened.values()].map((project) => project.session);
14048
+ opened.clear();
14049
+ await Promise.allSettled(sessions.map(async (session) => await session.close()));
14050
+ }
14051
+ };
14052
+ }
14053
+ function projectId(dir) {
14054
+ return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
14055
+ }
14056
+ function viewOf(dir, binding, isDefault) {
14057
+ return {
14058
+ id: projectId(dir),
14059
+ name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
14060
+ folder: tildify(dir),
14061
+ storage: storageOf(dir, binding),
14062
+ isDefault
14063
+ };
14064
+ }
14065
+ /** Where the board lives. The connection string is read here to name its host, and goes no further. */
14066
+ function storageOf(dir, binding) {
14067
+ if (binding?.databaseUrl) return {
14068
+ kind: "postgres",
14069
+ where: hostOf(maskDatabaseUrl(binding.databaseUrl))
14070
+ };
14071
+ return {
14072
+ kind: "file",
14073
+ path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
14074
+ };
14075
+ }
14076
+ /** The host of an already masked connection string; a string that is not one names no host. */
14077
+ function hostOf(maskedUrl) {
14078
+ try {
14079
+ const { host } = new URL(maskedUrl);
14080
+ return host && !host.includes("*") ? host : "a Postgres database";
14081
+ } catch {
14082
+ return "a Postgres database";
14083
+ }
14084
+ }
14085
+ //#endregion
13641
14086
  //#region src/cli/commands/serve.ts
13642
14087
  function registerServeCommand(program) {
13643
14088
  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) => {
@@ -13645,7 +14090,7 @@ function registerServeCommand(program) {
13645
14090
  status: (line) => console.error(line),
13646
14091
  page: (line) => console.log(line)
13647
14092
  });
13648
- await waitForInterrupt();
14093
+ if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
13649
14094
  try {
13650
14095
  await running.close();
13651
14096
  process.exit(0);
@@ -13657,26 +14102,46 @@ function registerServeCommand(program) {
13657
14102
  }
13658
14103
  /**
13659
14104
  * Open the board and serve it — everything `kanbo serve` does but wait. The
13660
- * command waits for Ctrl-C and ends the process; the home menu waits for
13661
- * Ctrl-C and comes back to its menu.
14105
+ * command waits for Ctrl-C (or the last board tab closing) and ends the
14106
+ * process; the home menu waits the same and comes back to its menu.
13662
14107
  */
13663
14108
  async function startServe(options, output) {
13664
14109
  const port = parsePort(options.port);
13665
14110
  const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
14111
+ const here = findBinding(process.cwd());
14112
+ const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
14113
+ const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
13666
14114
  const session = await openBoardSession({
13667
14115
  db: options.db,
13668
14116
  databaseUrl: options.databaseUrl,
13669
14117
  workspace: options.workspace
13670
- }, "read");
14118
+ }, "read", mostRecent?.path);
13671
14119
  const agentShell = isAgentShell();
13672
14120
  const actors = {
13673
- writer: createCliActor(),
14121
+ writer: createServeWriter(),
13674
14122
  person: agentShell ? null : createPersonActor()
13675
14123
  };
14124
+ const projects = createServeProjects({
14125
+ defaultProject: defaultDir === null ? null : {
14126
+ dir: defaultDir,
14127
+ session
14128
+ },
14129
+ readRegistry: () => readProjectRegistry(),
14130
+ readBinding: (dir) => readProjectBinding(dir),
14131
+ openSession: async (dir) => await openBoardSession({}, "read", dir),
14132
+ personWriterId: actors.writer.id
14133
+ });
14134
+ const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
14135
+ let presence;
14136
+ const idle = new Promise((resolve) => {
14137
+ presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
14138
+ });
13676
14139
  let server;
13677
14140
  try {
13678
14141
  server = await startKanboServer({
13679
14142
  session,
14143
+ projects,
14144
+ presence,
13680
14145
  actors,
13681
14146
  host: options.host,
13682
14147
  port,
@@ -13684,10 +14149,12 @@ async function startServe(options, output) {
13684
14149
  corsOrigins: options.corsOrigin
13685
14150
  });
13686
14151
  } catch (error) {
14152
+ presence?.stop();
13687
14153
  await session.close();
13688
14154
  throw error;
13689
14155
  }
13690
- output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
14156
+ const projectCount = projects.list().length;
14157
+ output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
13691
14158
  output.status(`kanbo serve: ${describePersonRights(actors)}`);
13692
14159
  presentServePage({
13693
14160
  url: server.url,
@@ -13701,25 +14168,40 @@ async function startServe(options, output) {
13701
14168
  });
13702
14169
  return {
13703
14170
  link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
14171
+ idle,
13704
14172
  close: async () => {
14173
+ presence?.stop();
13705
14174
  try {
13706
14175
  await server.close();
13707
14176
  } finally {
13708
- await session.close();
14177
+ try {
14178
+ await projects.close();
14179
+ } finally {
14180
+ await session.close();
14181
+ }
13709
14182
  }
13710
14183
  }
13711
14184
  };
13712
14185
  }
13713
- /** Settles on the first Ctrl-C (SIGINT) or SIGTERM, and stops listening for both. */
13714
- function waitForInterrupt() {
14186
+ /** Whether neither a flag nor the environment says which board to open. */
14187
+ function namesNoBoard(options) {
14188
+ return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
14189
+ }
14190
+ /**
14191
+ * Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
14192
+ * stops listening for both signals whichever it was.
14193
+ */
14194
+ function waitForStop(idle) {
13715
14195
  return new Promise((resolve) => {
13716
- const stop = () => {
13717
- process.off("SIGINT", stop);
13718
- process.off("SIGTERM", stop);
13719
- resolve();
14196
+ const finish = (why) => {
14197
+ process.off("SIGINT", onSignal);
14198
+ process.off("SIGTERM", onSignal);
14199
+ resolve(why);
13720
14200
  };
13721
- process.on("SIGINT", stop);
13722
- process.on("SIGTERM", stop);
14201
+ const onSignal = () => finish("interrupt");
14202
+ process.on("SIGINT", onSignal);
14203
+ process.on("SIGTERM", onSignal);
14204
+ idle.then(() => finish("idle"));
13723
14205
  });
13724
14206
  }
13725
14207
  function collect(value, previous) {
@@ -14043,7 +14525,7 @@ const KANBO_TOOL_CLI_EQUIVALENTS = {
14043
14525
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
14044
14526
  kanbo_card_list: { kanbo: "kanbo card list" },
14045
14527
  kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
14046
- kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
14528
+ kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
14047
14529
  kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
14048
14530
  kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
14049
14531
  kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },