kanbo-cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to kanbo are documented here. The format follows [Keep a Cha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] — 2026-11-07
8
+
9
+ kanbo 0.8 gives the board page Modular's look, puts sub-tasks inside their parent card, and adds an Analytics view and a List view. No board migration.
10
+
11
+ ### Added
12
+
13
+ - An **Analytics** view on the board page (`?view=analytics`): a range of 4, 12 or 26 weeks or a sprint, figure cards, finished per week, cumulative flow and time in column drawn in SVG, aging work, agents and people tables. It reads the same numbers as `kanbo stats` and fetches nothing while another view or a hidden tab is showing.
14
+ - `GET /issues/metrics` on `kanbo serve` (`from`, `to` in unix seconds, or `sprint=<id>`; the last 12 ISO weeks to now by default), the metrics `kanbo stats` prints. A read; additive.
15
+ - The board page: a **List view** (Board / List switch, `?view=list`), **column badges** (a count, `n/limit` against a WIP limit in amber at the limit, and a lock whose tooltip lists a column's rules), **keys** (`j`/`k`, arrows, Enter, Space, `x`, `/`, Esc) and **selecting several cards** (a box, `x`, shift- and cmd-click) with a floating bar that sets their column or priority in one `PATCH /issues/bulk`.
16
+ - The board page ships **Geist** (variable woff2 in latin, latin-ext and cyrillic, SIL OFL 1.1) under `/assets/fonts/`; the page's content security policy gains `font-src 'self'`.
17
+
18
+ ### Changed
19
+
20
+ - The board page takes Modular's look: Geist type, a near-white neutral palette in light and dark, white cards with a hairline ring on quiet column bays, a 28 px toolbar with **Filter** and **Display** menus, status-category and priority icons, and toned label chips. It replaces the strip-board design.
21
+ - **Sub-tasks** (a card with a parent) now live inside the parent's card as rows with a progress bar (`1/3 done`), and a waiting or running sub-task is never hidden (the parent shows a **Sub-task waiting for you** pill). **Display → Show sub-tasks as cards** (`?subtasks=cards`) draws them as cards of their own, marked **Sub-task of …**. The card dialog gets a breadcrumb to the parent, a sibling switcher, a **Sub-tasks** section and a property grid. "Sub-card" is now "sub-task" on the page.
22
+ - The `kanbo init` wizard no longer asks "Add a first card?" or "Write these changes?": it asks where the board lives, the card key, the columns and which agents to connect, then writes and prints what it did. `--first-card <title>` still adds a first card.
23
+ - In a folder with no board of its own, when only `KANBO_DB_PATH` names an app's database that is older than this kanbo or has no project for the folder, kanbo answers "This folder has no kanbo board of its own … run kanbo init here" instead of blaming the app's database. A binding or `--db` naming that database keeps the old sentence.
24
+
7
25
  ## [0.7.0] — 2026-11-07
8
26
 
9
27
  kanbo 0.7 gives agents one way onto the board: the MCP server. The terminal keeps the person's commands. No board migration.
@@ -247,6 +265,7 @@ First public release.
247
265
  - `kanbo serve`: the `/issues` HTTP API and a board page, loopback by default, bearer token, CORS allow-list, 1 MB body limit.
248
266
  - Library entry points `kanbo`, `kanbo/sqlite`, `kanbo/sqlite/schema`, `kanbo/postgres` and `kanbo/mcp` (ESM and CJS with types).
249
267
 
268
+ [0.8.0]: https://github.com/Krowli/kanbo/releases/tag/v0.8.0
250
269
  [0.7.0]: https://github.com/Krowli/kanbo/releases/tag/v0.7.0
251
270
  [0.6.0]: https://github.com/Krowli/kanbo/releases/tag/v0.6.0
252
271
  [0.5.0]: https://github.com/Krowli/kanbo/releases/tag/v0.5.0
package/README.md CHANGED
@@ -26,7 +26,7 @@ Then tell your agent: **"take the next card from kanbo."** Watch the board and a
26
26
  - **A board in a file, or a shared Postgres database.** `.kanbo/board.db` next to your code for one project on one machine, or an external Postgres (Supabase works) for a team of people and agents. See [storage](docs/storage.md).
27
27
  - **Agents work it over MCP.** Seventeen `kanbo_*` tools, and the server explains the rules itself when the client connects — nothing is written into your project's `CLAUDE.md` or `AGENTS.md`. See [MCP reference](docs/mcp.md); the [CLI reference](docs/cli.md) covers the commands you run.
28
28
  - **You approve. kanbo enforces it, not just asks.** `approve`, `return` and column changes are a person's; an agent's shell is refused with exit code `4`, and on Postgres the database roles refuse it too. See [agents](docs/agents.md#agents-and-people).
29
- - **Watch it in a terminal, or in your browser.** `kanbo board` and the interactive home screen, or `kanbo serve` for a board page over HTTP. See [HTTP API](docs/http-api.md).
29
+ - **Watch it in a terminal, or in your browser.** `kanbo board` and the interactive home screen, or `kanbo serve` for a board page over HTTP (sub-tasks inside their parent card, a List view, keys, bulk changes). See [HTTP API](docs/http-api.md).
30
30
 
31
31
  ## Works with
32
32
 
package/dist/cli-main.cjs CHANGED
@@ -3580,7 +3580,7 @@ new Command();
3580
3580
  * `package.version`. A build carries it as a literal; running from source
3581
3581
  * (tests, `tsx`) reads the package's own `package.json` instead.
3582
3582
  */
3583
- const KANBO_PACKAGE_VERSION = "0.7.0";
3583
+ const KANBO_PACKAGE_VERSION = "0.8.0";
3584
3584
  //#endregion
3585
3585
  //#region src/cli/agent-shell.ts
3586
3586
  /**
@@ -25061,11 +25061,28 @@ async function openBoardSession(options, mode, cwd = process.cwd()) {
25061
25061
  asAgent: options.asAgent,
25062
25062
  cwd
25063
25063
  });
25064
- const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
25064
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd).catch((error) => {
25065
+ throw describeEnvironmentOnlyFailure(error, target, options, cwd);
25066
+ });
25065
25067
  const found = findBinding(cwd);
25066
25068
  if (found) rememberProjectQuietly(found.projectDir);
25067
25069
  return session;
25068
25070
  }
25071
+ /**
25072
+ * A folder with no board of its own that only `KANBO_DB_PATH` points at an
25073
+ * app's database: when that database is older than this kanbo, or knows no
25074
+ * project for the folder, the folder is told it has no board, not that "the
25075
+ * app's database" needs updating — the person never chose that database. A
25076
+ * flag or a binding that names the database keeps the app-centred sentence.
25077
+ */
25078
+ function describeEnvironmentOnlyFailure(error, target, options, cwd) {
25079
+ if (!(target.owner !== "kanbo" && !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(cwd) === null) || !(error instanceof CliError)) return error;
25080
+ let reason;
25081
+ if (error.exitCode === 3 && error.message === "The app's database is older than this kanbo. Let the app it belongs to update it first.") reason = "is older than this kanbo";
25082
+ else if (error.exitCode === 2 && error.message === "Could not tell which project this is. Pass --workspace <name|id>, or run \"kanbo init\" in the project folder.") reason = "has no project for this folder";
25083
+ else return error;
25084
+ return new CliError(error.exitCode, `This folder has no kanbo board of its own. KANBO_DB_PATH points at another app's database (${target.path}), which ${reason}. To give this folder its own board, run kanbo init here.`);
25085
+ }
25069
25086
  async function openFileSession(target, options, mode, cwd) {
25070
25087
  const board = await openBoardDatabase(target.path);
25071
25088
  try {
@@ -43176,7 +43193,7 @@ function startUpdateCheck(deps = {}) {
43176
43193
  setTimeout: globalThis.setTimeout,
43177
43194
  clearTimeout: globalThis.clearTimeout
43178
43195
  };
43179
- const current = deps.currentVersion ?? "0.7.0";
43196
+ const current = deps.currentVersion ?? "0.8.0";
43180
43197
  const controller = new AbortController();
43181
43198
  let found = null;
43182
43199
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -44430,26 +44447,6 @@ function assertOwnBoardFile(absolutePath) {
44430
44447
  function notABoardFileMessage(absolutePath) {
44431
44448
  return `${absolutePath} already holds a database of its own. Name another path with --file, or move that file out of the way.`;
44432
44449
  }
44433
- /** The plan as a person reads it before saying yes. */
44434
- function describeInitPlan(plan) {
44435
- const lines = ["kanbo init will:"];
44436
- const columns = plan.columns ? `, with the columns ${plan.columns.map((column) => column.name).join(", ")}` : "";
44437
- if (plan.boardFile) {
44438
- const what = plan.boardFile.exists ? "the board file of this project, brought up to date" : `a board file of this project's own${columns}`;
44439
- lines.push(` ${plan.boardFile.exists ? "update" : "create"} ${relativeTo(plan, plan.boardFile.path)} (${what})`);
44440
- }
44441
- if (plan.target.kind === "postgres" && plan.migrate) lines.push(` create the board's tables in ${maskDatabaseUrl(plan.target.url)}${columns}`);
44442
- lines.push(` write ${BINDING_FILE_PATH} (this project uses board ${plan.workspace.name})`);
44443
- for (const item of plan.connect ? pendingItems(plan.connect) : []) lines.push(item.kind === "claude-cli" ? ` run ${explainItem(item)}` : ` ${item.kind === "instructions" ? "remove " : "write "} ${displayPath(plan, itemPath(item))} (${explainItem(item)})`);
44444
- if (plan.firstCard) lines.push(` add a first card to To Do: ${plan.firstCard}`);
44445
- for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
44446
- lines.push("Nothing else is changed.");
44447
- return lines.join("\n");
44448
- }
44449
- function relativeTo(plan, path) {
44450
- const inside = (0, node_path.relative)(plan.projectDir, path);
44451
- return inside.startsWith("..") ? path : inside;
44452
- }
44453
44450
  /**
44454
44451
  * Carry the plan out: the board first — the file, or the shared database's
44455
44452
  * tables — so a migration that fails leaves no binding pointing at nothing;
@@ -44631,21 +44628,6 @@ async function runInitWizard(context) {
44631
44628
  }
44632
44629
  }
44633
44630
  const plan = await context.plan(projectDir, options);
44634
- if (options.firstCard === void 0 && canAddCard(plan)) {
44635
- const title = (await ui.text({
44636
- message: "Add a first card? (optional — Enter to skip)",
44637
- placeholder: "e.g. Add a README"
44638
- })).trim();
44639
- if (title) {
44640
- options.firstCard = title;
44641
- plan.firstCard = title;
44642
- }
44643
- }
44644
- say$1(ui, describeInitPlan(plan));
44645
- if (!await ui.confirm({
44646
- message: "Write these changes?",
44647
- initialValue: true
44648
- })) throw new CliError(1, "Nothing was written.");
44649
44631
  return {
44650
44632
  projectDir,
44651
44633
  options,
@@ -44891,10 +44873,6 @@ async function askConnect(ui) {
44891
44873
  }]
44892
44874
  });
44893
44875
  }
44894
- /** A shared board whose tables were not created here may have none yet, so there is nowhere to put a card. */
44895
- function canAddCard(plan) {
44896
- return plan.target.kind !== "postgres" || plan.migrate;
44897
- }
44898
44876
  /** (9) What was done, and what to do next. */
44899
44877
  function printInitOutro(ui, result, applied) {
44900
44878
  const { plan } = result;
@@ -48741,12 +48719,14 @@ const BOARD_PAGE_CSP = [
48741
48719
  "style-src 'self'",
48742
48720
  "connect-src 'self'",
48743
48721
  "img-src 'self' data:",
48722
+ "font-src 'self'",
48744
48723
  "base-uri 'none'",
48745
48724
  "form-action 'none'",
48746
48725
  "frame-ancestors 'none'"
48747
48726
  ].join("; ");
48748
48727
  const SCRIPT_PATH = "/assets/board.js";
48749
48728
  const STYLESHEET_PATH = "/assets/board.css";
48729
+ const FONT_PATH = /^\/assets\/fonts\/([a-z0-9-]+\.woff2)$/;
48750
48730
  /** The document itself. No data in it; the `data:` icon keeps the browser from asking for `/favicon.ico`. */
48751
48731
  const BOARD_PAGE_HTML = `<!doctype html>
48752
48732
  <html lang="en">
@@ -48773,19 +48753,23 @@ function resolveBoardPageDirectory() {
48773
48753
  const candidates = [(0, node_path.join)(here, "page"), (0, node_path.join)(here, "..", "..", "dist", "page")];
48774
48754
  return candidates.find((directory) => (0, node_fs.existsSync)((0, node_path.join)(directory, "board.js"))) ?? candidates[1];
48775
48755
  }
48776
- /** The page's two files from `directory`, or `null` when the page has not been built there. */
48756
+ /** The page's files from `directory`, or `null` when the page has not been built there. */
48777
48757
  function readBoardPageAssets(directory) {
48778
48758
  const script = (0, node_path.join)(directory, "board.js");
48779
48759
  const stylesheet = (0, node_path.join)(directory, "board.css");
48780
48760
  if (!(0, node_fs.existsSync)(script) || !(0, node_fs.existsSync)(stylesheet)) return null;
48761
+ const fontDirectory = (0, node_path.join)(directory, "fonts");
48762
+ const fonts = /* @__PURE__ */ new Map();
48763
+ if ((0, node_fs.existsSync)(fontDirectory)) for (const name of (0, node_fs.readdirSync)(fontDirectory).filter((file) => FONT_PATH.test(`/assets/fonts/${file}`))) fonts.set(name, (0, node_fs.readFileSync)((0, node_path.join)(fontDirectory, name)));
48781
48764
  return {
48782
48765
  script: (0, node_fs.readFileSync)(script, "utf8"),
48783
- stylesheet: (0, node_fs.readFileSync)(stylesheet, "utf8")
48766
+ stylesheet: (0, node_fs.readFileSync)(stylesheet, "utf8"),
48767
+ fonts
48784
48768
  };
48785
48769
  }
48786
48770
  /** Whether a path is one of the page's own. */
48787
48771
  function isBoardPagePath(pathname) {
48788
- return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH;
48772
+ return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH || FONT_PATH.test(pathname);
48789
48773
  }
48790
48774
  /**
48791
48775
  * Answer a page path. With no built page, `/` says how to build it rather than
@@ -48804,6 +48788,31 @@ function sendBoardPage(response, pathname, assets) {
48804
48788
  response.end(body);
48805
48789
  return;
48806
48790
  }
48791
+ const fontName = FONT_PATH.exec(pathname)?.[1];
48792
+ if (fontName !== void 0) {
48793
+ const font = assets.fonts.get(fontName);
48794
+ if (!font) {
48795
+ const body = JSON.stringify({
48796
+ code: "not_found",
48797
+ message: "No such font"
48798
+ });
48799
+ response.writeHead(404, {
48800
+ "content-type": "application/json",
48801
+ "content-length": Buffer.byteLength(body)
48802
+ });
48803
+ response.end(body);
48804
+ return;
48805
+ }
48806
+ response.writeHead(200, {
48807
+ "content-type": "font/woff2",
48808
+ "content-length": font.length,
48809
+ "cache-control": "no-cache",
48810
+ "x-content-type-options": "nosniff",
48811
+ "referrer-policy": "no-referrer"
48812
+ });
48813
+ response.end(font);
48814
+ return;
48815
+ }
48807
48816
  const [contentType, body] = pathname === SCRIPT_PATH ? ["text/javascript; charset=utf-8", assets.script] : pathname === STYLESHEET_PATH ? ["text/css; charset=utf-8", assets.stylesheet] : ["text/html; charset=utf-8", BOARD_PAGE_HTML];
48808
48817
  response.writeHead(200, {
48809
48818
  "content-type": contentType,
@@ -49149,6 +49158,113 @@ function splitPath(path) {
49149
49158
  });
49150
49159
  }
49151
49160
  //#endregion
49161
+ //#region src/cli/metrics-view.ts
49162
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
49163
+ const BOARD_METRICS_FIELDS = [
49164
+ "range",
49165
+ "now",
49166
+ "milestoneId",
49167
+ "columns",
49168
+ "cycleTime",
49169
+ "leadTime",
49170
+ "throughput",
49171
+ "wip",
49172
+ "aging",
49173
+ "timeInColumn",
49174
+ "cumulativeFlow",
49175
+ "finished",
49176
+ "returns",
49177
+ "attempts",
49178
+ "canceled",
49179
+ "agents",
49180
+ "people"
49181
+ ];
49182
+ const LABEL_WIDTH = 16;
49183
+ /** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
49184
+ function formatDuration(seconds) {
49185
+ if (seconds === null) return "—";
49186
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
49187
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
49188
+ return `${Number((seconds / 86400).toFixed(1))}d`;
49189
+ }
49190
+ function percent(share) {
49191
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
49192
+ }
49193
+ function decimal(value) {
49194
+ return value === null ? "—" : String(Number(value.toFixed(1)));
49195
+ }
49196
+ function count(n, noun) {
49197
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
49198
+ }
49199
+ function day(seconds) {
49200
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
49201
+ }
49202
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
49203
+ function block(label, items) {
49204
+ const [first = "—", ...rest] = items;
49205
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
49206
+ }
49207
+ /** Items joined on one line, or none at all. */
49208
+ function oneLine(items) {
49209
+ return items.length === 0 ? [] : [items.join(" · ")];
49210
+ }
49211
+ /**
49212
+ * The metrics as a person reads them in a terminal: the range, then one block
49213
+ * per figure. The cumulative flow is a series per day and has no one-screen
49214
+ * form, so the last line says how to get it.
49215
+ */
49216
+ function describeMetrics(metrics, sprint) {
49217
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
49218
+ const by = metrics.returns.byActorKind;
49219
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
49220
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
49221
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
49222
+ lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
49223
+ return lines.join("\n");
49224
+ }
49225
+ function describeAgent(agent) {
49226
+ const { runs } = agent;
49227
+ return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
49228
+ }
49229
+ function describePerson(person) {
49230
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
49231
+ }
49232
+ const WEEK_SECONDS = 604800;
49233
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
49234
+ function defaultStatsFrom(now) {
49235
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
49236
+ }
49237
+ /**
49238
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
49239
+ *
49240
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
49241
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
49242
+ * win over either end.
49243
+ */
49244
+ function registerStatsCommand(program) {
49245
+ withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
49246
+ await runBoardCommand(options, "read", async (session) => {
49247
+ const now = currentUnixSeconds();
49248
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
49249
+ if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
49250
+ milestoneId: options.sprint,
49251
+ workspaceId: session.workspace.id
49252
+ });
49253
+ const metrics = await session.ops.boardMetrics({
49254
+ workspaceIds: [session.workspace.id],
49255
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
49256
+ to: options.to ?? sprint?.dueDate ?? now,
49257
+ milestoneId: sprint?.id ?? null
49258
+ }, now);
49259
+ return {
49260
+ value: metrics,
49261
+ text: describeMetrics(metrics, sprint),
49262
+ fields: BOARD_METRICS_FIELDS
49263
+ };
49264
+ });
49265
+ });
49266
+ }
49267
+ //#endregion
49152
49268
  //#region src/serve/schemas.ts
49153
49269
  /**
49154
49270
  * The request shapes `kanbo serve` takes: the bodies and queries of the
@@ -49490,6 +49606,14 @@ function createActivityResolver(workspace, personWriterId = null) {
49490
49606
  * a server started for one of them must not reach into the rest.
49491
49607
  */
49492
49608
  const OK = { ok: true };
49609
+ /** Unix seconds in a query string, where everything arrives as text. */
49610
+ const unixSecondsQuery = string().regex(/^\d+$/).transform(Number);
49611
+ /** `GET /issues/metrics`: the range as `kanbo stats` takes it, `from` and `to` in unix seconds, or a sprint to read over its own dates. */
49612
+ const metricsQuery = object$1({
49613
+ from: unixSecondsQuery.optional(),
49614
+ to: unixSecondsQuery.optional(),
49615
+ sprint: string().min(1).optional()
49616
+ });
49493
49617
  /** How many cards a search answers with when the caller names no limit. */
49494
49618
  const DEFAULT_SEARCH_LIMIT = 20;
49495
49619
  /**
@@ -49785,6 +49909,25 @@ function createIssueRoutes(session, options = {}) {
49785
49909
  return updated;
49786
49910
  }
49787
49911
  }),
49912
+ route({
49913
+ method: "GET",
49914
+ path: "/issues/metrics",
49915
+ query: metricsQuery,
49916
+ handle: async ({ query }) => {
49917
+ const now = currentUnixSeconds();
49918
+ const sprint = query.sprint === void 0 ? null : (await ops.listSprints(workspaceId, now)).find((candidate) => candidate.id === query.sprint) ?? null;
49919
+ if (query.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
49920
+ milestoneId: query.sprint,
49921
+ workspaceId
49922
+ });
49923
+ return await ops.boardMetrics({
49924
+ workspaceIds: [workspaceId],
49925
+ from: query.from ?? sprint?.startDate ?? defaultStatsFrom(now),
49926
+ to: query.to ?? sprint?.dueDate ?? now,
49927
+ milestoneId: sprint?.id ?? null
49928
+ }, now);
49929
+ }
49930
+ }),
49788
49931
  route({
49789
49932
  method: "GET",
49790
49933
  path: "/issues/workspace",
@@ -50785,113 +50928,6 @@ function registerSprintCommands(program) {
50785
50928
  });
50786
50929
  }
50787
50930
  //#endregion
50788
- //#region src/cli/metrics-view.ts
50789
- /** Every field `kanbo stats --json` may name, in the order the value carries them. */
50790
- const BOARD_METRICS_FIELDS = [
50791
- "range",
50792
- "now",
50793
- "milestoneId",
50794
- "columns",
50795
- "cycleTime",
50796
- "leadTime",
50797
- "throughput",
50798
- "wip",
50799
- "aging",
50800
- "timeInColumn",
50801
- "cumulativeFlow",
50802
- "finished",
50803
- "returns",
50804
- "attempts",
50805
- "canceled",
50806
- "agents",
50807
- "people"
50808
- ];
50809
- const LABEL_WIDTH = 16;
50810
- /** A duration as the command line prints it: minutes under an hour, hours under a day, then days, to one decimal; a dash for none. */
50811
- function formatDuration(seconds) {
50812
- if (seconds === null) return "—";
50813
- if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
50814
- if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
50815
- return `${Number((seconds / 86400).toFixed(1))}d`;
50816
- }
50817
- function percent(share) {
50818
- return share === null ? "—" : `${Math.round(share * 100)}%`;
50819
- }
50820
- function decimal(value) {
50821
- return value === null ? "—" : String(Number(value.toFixed(1)));
50822
- }
50823
- function count(n, noun) {
50824
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
50825
- }
50826
- function day(seconds) {
50827
- return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
50828
- }
50829
- /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
50830
- function block(label, items) {
50831
- const [first = "—", ...rest] = items;
50832
- return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
50833
- }
50834
- /** Items joined on one line, or none at all. */
50835
- function oneLine(items) {
50836
- return items.length === 0 ? [] : [items.join(" · ")];
50837
- }
50838
- /**
50839
- * The metrics as a person reads them in a terminal: the range, then one block
50840
- * per figure. The cumulative flow is a series per day and has no one-screen
50841
- * form, so the last line says how to get it.
50842
- */
50843
- function describeMetrics(metrics, sprint) {
50844
- const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
50845
- const by = metrics.returns.byActorKind;
50846
- const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
50847
- if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
50848
- if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
50849
- lines.push(...block("Cycle time", [`p50 ${formatDuration(metrics.cycleTime.p50)} · p85 ${formatDuration(metrics.cycleTime.p85)} (${count(metrics.cycleTime.cards, "card")})`]), ...block("Lead time", [`p50 ${formatDuration(metrics.leadTime.p50)} · p85 ${formatDuration(metrics.leadTime.p85)}`]), ...block("Throughput", oneLine(metrics.throughput.map((week) => `${week.week} ${week.finished}`))), ...block("In progress", oneLine(metrics.wip.map((wip) => `${column(wip.slug)} ${wip.cards}${wip.limit === null ? "" : `/${wip.limit}`}`))), ...block("First pass", [`${percent(metrics.returns.firstPassRate)} · attempts ${decimal(metrics.attempts.perFinishedCard)} per card`]), ...block("Returns", [`${metrics.returns.total} (people ${by.user} · agents ${by.agent} · outside ${by.external}) · ${decimal(metrics.returns.perFinishedCard)} per card`]), ...block("Canceled", [String(metrics.canceled)]), ...block("Aging", metrics.aging.map((card) => `${card.issueId} ${column(card.slug)} ${formatDuration(card.ageSeconds)}${card.overCycleP85 ? " (older than p85)" : ""}`)), ...block("Time in column", oneLine(metrics.timeInColumn.filter((time) => time.seconds > 0).map((time) => `${column(time.slug)} ${formatDuration(time.seconds)}`))), ...block("Agents", metrics.agents.map(describeAgent)), ...block("People", metrics.people.map(describePerson)), "Cumulative flow by day: kanbo stats --json cumulativeFlow");
50850
- return lines.join("\n");
50851
- }
50852
- function describeAgent(agent) {
50853
- const { runs } = agent;
50854
- return `${sanitizeTerminalText(agent.agentName)}: ${count(runs.total, "run")} (${runs.finished} finished · ${runs.failed} failed · ${runs.stopped} stopped · ${runs.running} running) · median ${formatDuration(agent.medianRunSeconds)} · ${count(agent.cardsFinished, "card")} finished · ${percent(agent.returnRate)} returned`;
50855
- }
50856
- function describePerson(person) {
50857
- return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
50858
- }
50859
- const WEEK_SECONDS = 604800;
50860
- /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
50861
- function defaultStatsFrom(now) {
50862
- return isoWeekOf(now).start - 11 * WEEK_SECONDS;
50863
- }
50864
- /**
50865
- * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
50866
- *
50867
- * Reading only, so it is anybody's, an agent's shell included. A sprint sets
50868
- * the range to its own dates and keeps its cards; `--from` and `--to` still
50869
- * win over either end.
50870
- */
50871
- function registerStatsCommand(program) {
50872
- withBoardOptions(program.command("stats").description("How work flows on this board: cycle time, throughput, work in progress, returns, agents").option("--from <date>", `first day, YYYY-MM-DD (UTC) or unix seconds (12 weeks back when left out)`, (value) => parseCalendarDay(value, false)).option("--to <date>", "last day, YYYY-MM-DD (UTC, through its end) or unix seconds (now when left out)", (value) => parseCalendarDay(value, true)).option("--sprint <id>", "only the cards of this sprint, over its dates")).action(async (options) => {
50873
- await runBoardCommand(options, "read", async (session) => {
50874
- const now = currentUnixSeconds();
50875
- const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
50876
- if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
50877
- milestoneId: options.sprint,
50878
- workspaceId: session.workspace.id
50879
- });
50880
- const metrics = await session.ops.boardMetrics({
50881
- workspaceIds: [session.workspace.id],
50882
- from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
50883
- to: options.to ?? sprint?.dueDate ?? now,
50884
- milestoneId: sprint?.id ?? null
50885
- }, now);
50886
- return {
50887
- value: metrics,
50888
- text: describeMetrics(metrics, sprint),
50889
- fields: BOARD_METRICS_FIELDS
50890
- };
50891
- });
50892
- });
50893
- }
50894
- //#endregion
50895
50931
  //#region src/cli/commands/uninstall.ts
50896
50932
  function registerUninstallCommand(program) {
50897
50933
  program.command("uninstall").description("Disconnect kanbo from your agents; the board stays unless --purge").option("--project", "only this project's files").option("--global", "only your own files, for every project").option("--purge", "also remove this project's board settings, and its board file after you confirm").option("--yes", "do it without asking (never deletes a board file)").option("--json [fields]", "print what was done as JSON; name comma-separated fields to print only those").action(async (options) => {
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_tool_names = require("./tool-names-CjIo1rdF.cjs");
2
+ const require_tool_names = require("./tool-names-BOUJas7m.cjs");
3
3
  const require_migrations_path = require("./migrations-path-DBCE17dT.cjs");
4
4
  exports.BINDING_FILE_PATH = require_tool_names.BINDING_FILE_PATH;
5
5
  exports.BOARD_ACTOR = require_tool_names.BOARD_ACTOR;
@@ -1,3 +1,3 @@
1
- import { $ as readChangeSeq, A as suggestCardKey, At as nextIssueIdentity, B as formatExternalSessionRef, Ct as isEmptyActivityValue, D as renderCapabilitiesMarkdown, Dt as normalizeCommentAuthorKind, Et as isOutsideWriter, F as ignoreBoardFile, G as UNMET_ENTRY_RULE_REASONS, H as pullRequestFactKeys, I as readBinding, J as readEntryRules, K as evaluateEntryRules, L as readProjectBinding, M as maskDatabaseUrl, Mt as nextIssueOrder, N as maskDatabaseUrls, Nt as readIssuePrefix, O as CARD_KEY_PATTERN, Ot as toIssueView, P as BINDING_FILE_PATH, Q as COLUMN_TEMPLATE_IDS, R as writeBinding, St as formatPlainActivityValue, Tt as readSystemCommentKind, U as ENTRY_RULES, V as parseExternalSessionRef, W as ENTRY_RULE_DESCRIPTIONS, X as COLUMN_CATALOGUE, Y as serializeEntryRules, Z as COLUMN_TEMPLATES, _t as activityKindOrder, at as computeBoardMetrics, bt as commentAuthorReference, ct as utcDayLabel, dt as DEFAULT_STATUSES, et as runBoardWrite, ft as normalizeStatusName, gt as currentUnixSeconds, ht as BOARD_ACTOR, i as BOARD_ERROR_RESPONSES, it as boardMetricsRuns, j as DEFAULT_BOARD_FILE_PATH, jt as nextIssueNumber, k as isValidCardKey, kt as formatIssueId, lt as FLOW_RULES, n as CAPABILITIES_MANIFEST_VERSION, nt as diffFieldChanges, ot as nearestRank, pt as cardDisplayTitle, q as readChecklist, r as buildCapabilitiesManifest, rt as boardMetricsAgentKey, st as isoWeekOf, t as KANBO_TOOL_NAMES, tt as TRACKED_FIELDS, ut as readFlowRules, vt as activityText, wt as parseActivityStringArray, xt as formatActivityIssueReference, yt as activityToken, z as createBoardOps } from "./tool-names-CGn-8BYp.mjs";
1
+ import { $ as readChangeSeq, A as suggestCardKey, At as nextIssueIdentity, B as formatExternalSessionRef, Ct as isEmptyActivityValue, D as renderCapabilitiesMarkdown, Dt as normalizeCommentAuthorKind, Et as isOutsideWriter, F as ignoreBoardFile, G as UNMET_ENTRY_RULE_REASONS, H as pullRequestFactKeys, I as readBinding, J as readEntryRules, K as evaluateEntryRules, L as readProjectBinding, M as maskDatabaseUrl, Mt as nextIssueOrder, N as maskDatabaseUrls, Nt as readIssuePrefix, O as CARD_KEY_PATTERN, Ot as toIssueView, P as BINDING_FILE_PATH, Q as COLUMN_TEMPLATE_IDS, R as writeBinding, St as formatPlainActivityValue, Tt as readSystemCommentKind, U as ENTRY_RULES, V as parseExternalSessionRef, W as ENTRY_RULE_DESCRIPTIONS, X as COLUMN_CATALOGUE, Y as serializeEntryRules, Z as COLUMN_TEMPLATES, _t as activityKindOrder, at as computeBoardMetrics, bt as commentAuthorReference, ct as utcDayLabel, dt as DEFAULT_STATUSES, et as runBoardWrite, ft as normalizeStatusName, gt as currentUnixSeconds, ht as BOARD_ACTOR, i as BOARD_ERROR_RESPONSES, it as boardMetricsRuns, j as DEFAULT_BOARD_FILE_PATH, jt as nextIssueNumber, k as isValidCardKey, kt as formatIssueId, lt as FLOW_RULES, n as CAPABILITIES_MANIFEST_VERSION, nt as diffFieldChanges, ot as nearestRank, pt as cardDisplayTitle, q as readChecklist, r as buildCapabilitiesManifest, rt as boardMetricsAgentKey, st as isoWeekOf, t as KANBO_TOOL_NAMES, tt as TRACKED_FIELDS, ut as readFlowRules, vt as activityText, wt as parseActivityStringArray, xt as formatActivityIssueReference, yt as activityToken, z as createBoardOps } from "./tool-names-CrvHZ-ON.mjs";
2
2
  import { a as BoardError } from "./migrations-path-ClaYRNwk.mjs";
3
3
  export { BINDING_FILE_PATH, BOARD_ACTOR, BOARD_ERROR_RESPONSES, BoardError, CAPABILITIES_MANIFEST_VERSION, CARD_KEY_PATTERN, COLUMN_CATALOGUE, COLUMN_TEMPLATES, COLUMN_TEMPLATE_IDS, DEFAULT_BOARD_FILE_PATH, DEFAULT_STATUSES, ENTRY_RULES, ENTRY_RULE_DESCRIPTIONS, FLOW_RULES, KANBO_TOOL_NAMES, TRACKED_FIELDS, UNMET_ENTRY_RULE_REASONS, activityKindOrder, activityText, activityToken, boardMetricsAgentKey, boardMetricsRuns, buildCapabilitiesManifest, cardDisplayTitle, commentAuthorReference, computeBoardMetrics, createBoardOps, currentUnixSeconds, diffFieldChanges, evaluateEntryRules, formatActivityIssueReference, formatExternalSessionRef, formatIssueId, formatPlainActivityValue, ignoreBoardFile, isEmptyActivityValue, isOutsideWriter, isValidCardKey, isoWeekOf, maskDatabaseUrl, maskDatabaseUrls, nearestRank, nextIssueIdentity, nextIssueNumber, nextIssueOrder, normalizeCommentAuthorKind, normalizeStatusName, parseActivityStringArray, parseExternalSessionRef, pullRequestFactKeys, readBinding, readChangeSeq, readChecklist, readEntryRules, readFlowRules, readIssuePrefix, readProjectBinding, readSystemCommentKind, renderCapabilitiesMarkdown, runBoardWrite, serializeEntryRules, suggestCardKey, toIssueView, utcDayLabel, writeBinding };
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_tool_names = require("../tool-names-CjIo1rdF.cjs");
2
+ const require_tool_names = require("../tool-names-BOUJas7m.cjs");
3
3
  const require_migrations_path = require("../migrations-path-DBCE17dT.cjs");
4
4
  let zod = require("zod");
5
5
  //#region src/mcp/http-transport.ts
@@ -1,4 +1,4 @@
1
- import { C as KANBO_MCP_INSTRUCTIONS, E as registerKanboCapabilitiesResources, S as projectKanboSubCard, T as KANBO_CAPABILITIES_RESOURCE_URI, _ as projectKanboColumn, a as KANBO_MCP_SERVER_NAME, b as projectKanboRun, c as KANBO_TOOLS, d as DEFAULT_KANBO_COMMENT_LIMIT, f as KANBO_CARD_INCLUDES, ft as normalizeStatusName, g as projectKanboCardComment, h as projectKanboCard, l as buildKanboTools, m as pageOf, mt as previewComment, o as createKanboMcpServer, p as matchesKanboCardQuery, s as registerKanboTools, t as KANBO_TOOL_NAMES, u as DEFAULT_KANBO_CARD_INCLUDES, v as projectKanboFieldChange, w as KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI, x as projectKanboSprint, y as projectKanboPullRequest } from "../tool-names-CGn-8BYp.mjs";
1
+ import { C as KANBO_MCP_INSTRUCTIONS, E as registerKanboCapabilitiesResources, S as projectKanboSubCard, T as KANBO_CAPABILITIES_RESOURCE_URI, _ as projectKanboColumn, a as KANBO_MCP_SERVER_NAME, b as projectKanboRun, c as KANBO_TOOLS, d as DEFAULT_KANBO_COMMENT_LIMIT, f as KANBO_CARD_INCLUDES, ft as normalizeStatusName, g as projectKanboCardComment, h as projectKanboCard, l as buildKanboTools, m as pageOf, mt as previewComment, o as createKanboMcpServer, p as matchesKanboCardQuery, s as registerKanboTools, t as KANBO_TOOL_NAMES, u as DEFAULT_KANBO_CARD_INCLUDES, v as projectKanboFieldChange, w as KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI, x as projectKanboSprint, y as projectKanboPullRequest } from "../tool-names-CrvHZ-ON.mjs";
2
2
  import { a as BoardError } from "../migrations-path-ClaYRNwk.mjs";
3
3
  import { z } from "zod";
4
4
  //#region src/mcp/http-transport.ts