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.
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { homedir, hostname, userInfo } from "node:os";
7
7
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
8
  import { sql } from "drizzle-orm";
9
- import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
10
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
  import { drizzle } from "drizzle-orm/postgres-js";
12
12
  import { migrate } from "drizzle-orm/postgres-js/migrator";
@@ -5455,11 +5455,28 @@ async function openBoardSession(options, mode, cwd = process.cwd()) {
5455
5455
  asAgent: options.asAgent,
5456
5456
  cwd
5457
5457
  });
5458
- const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5458
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd).catch((error) => {
5459
+ throw describeEnvironmentOnlyFailure(error, target, options, cwd);
5460
+ });
5459
5461
  const found = findBinding(cwd);
5460
5462
  if (found) rememberProjectQuietly(found.projectDir);
5461
5463
  return session;
5462
5464
  }
5465
+ /**
5466
+ * A folder with no board of its own that only `KANBO_DB_PATH` points at an
5467
+ * app's database: when that database is older than this kanbo, or knows no
5468
+ * project for the folder, the folder is told it has no board, not that "the
5469
+ * app's database" needs updating — the person never chose that database. A
5470
+ * flag or a binding that names the database keeps the app-centred sentence.
5471
+ */
5472
+ function describeEnvironmentOnlyFailure(error, target, options, cwd) {
5473
+ if (!(target.owner !== "kanbo" && !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(cwd) === null) || !(error instanceof CliError)) return error;
5474
+ let reason;
5475
+ 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";
5476
+ 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";
5477
+ else return error;
5478
+ 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.`);
5479
+ }
5463
5480
  async function openFileSession(target, options, mode, cwd) {
5464
5481
  const board = await openBoardDatabase(target.path);
5465
5482
  try {
@@ -6824,7 +6841,7 @@ dirname(fileURLToPath(import.meta.url));
6824
6841
  * `package.version`. A build carries it as a literal; running from source
6825
6842
  * (tests, `tsx`) reads the package's own `package.json` instead.
6826
6843
  */
6827
- const KANBO_PACKAGE_VERSION = "0.7.0";
6844
+ const KANBO_PACKAGE_VERSION = "0.8.0";
6828
6845
  //#endregion
6829
6846
  //#region src/cli/setup/paths.ts
6830
6847
  /** Where Codex keeps its global files: `$CODEX_HOME`, else `~/.codex`. */
@@ -8162,7 +8179,7 @@ function startUpdateCheck(deps = {}) {
8162
8179
  setTimeout: globalThis.setTimeout,
8163
8180
  clearTimeout: globalThis.clearTimeout
8164
8181
  };
8165
- const current = deps.currentVersion ?? "0.7.0";
8182
+ const current = deps.currentVersion ?? "0.8.0";
8166
8183
  const controller = new AbortController();
8167
8184
  let found = null;
8168
8185
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -9331,26 +9348,6 @@ function assertOwnBoardFile(absolutePath) {
9331
9348
  function notABoardFileMessage(absolutePath) {
9332
9349
  return `${absolutePath} already holds a database of its own. Name another path with --file, or move that file out of the way.`;
9333
9350
  }
9334
- /** The plan as a person reads it before saying yes. */
9335
- function describeInitPlan(plan) {
9336
- const lines = ["kanbo init will:"];
9337
- const columns = plan.columns ? `, with the columns ${plan.columns.map((column) => column.name).join(", ")}` : "";
9338
- if (plan.boardFile) {
9339
- const what = plan.boardFile.exists ? "the board file of this project, brought up to date" : `a board file of this project's own${columns}`;
9340
- lines.push(` ${plan.boardFile.exists ? "update" : "create"} ${relativeTo(plan, plan.boardFile.path)} (${what})`);
9341
- }
9342
- if (plan.target.kind === "postgres" && plan.migrate) lines.push(` create the board's tables in ${maskDatabaseUrl(plan.target.url)}${columns}`);
9343
- lines.push(` write ${BINDING_FILE_PATH} (this project uses board ${plan.workspace.name})`);
9344
- 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)})`);
9345
- if (plan.firstCard) lines.push(` add a first card to To Do: ${plan.firstCard}`);
9346
- for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
9347
- lines.push("Nothing else is changed.");
9348
- return lines.join("\n");
9349
- }
9350
- function relativeTo(plan, path) {
9351
- const inside = relative(plan.projectDir, path);
9352
- return inside.startsWith("..") ? path : inside;
9353
- }
9354
9351
  /**
9355
9352
  * Carry the plan out: the board first — the file, or the shared database's
9356
9353
  * tables — so a migration that fails leaves no binding pointing at nothing;
@@ -9532,21 +9529,6 @@ async function runInitWizard(context) {
9532
9529
  }
9533
9530
  }
9534
9531
  const plan = await context.plan(projectDir, options);
9535
- if (options.firstCard === void 0 && canAddCard(plan)) {
9536
- const title = (await ui.text({
9537
- message: "Add a first card? (optional — Enter to skip)",
9538
- placeholder: "e.g. Add a README"
9539
- })).trim();
9540
- if (title) {
9541
- options.firstCard = title;
9542
- plan.firstCard = title;
9543
- }
9544
- }
9545
- say(ui, describeInitPlan(plan));
9546
- if (!await ui.confirm({
9547
- message: "Write these changes?",
9548
- initialValue: true
9549
- })) throw new CliError(1, "Nothing was written.");
9550
9532
  return {
9551
9533
  projectDir,
9552
9534
  options,
@@ -9792,10 +9774,6 @@ async function askConnect(ui) {
9792
9774
  }]
9793
9775
  });
9794
9776
  }
9795
- /** A shared board whose tables were not created here may have none yet, so there is nowhere to put a card. */
9796
- function canAddCard(plan) {
9797
- return plan.target.kind !== "postgres" || plan.migrate;
9798
- }
9799
9777
  /** (9) What was done, and what to do next. */
9800
9778
  function printInitOutro(ui, result, applied) {
9801
9779
  const { plan } = result;
@@ -12000,12 +11978,14 @@ const BOARD_PAGE_CSP = [
12000
11978
  "style-src 'self'",
12001
11979
  "connect-src 'self'",
12002
11980
  "img-src 'self' data:",
11981
+ "font-src 'self'",
12003
11982
  "base-uri 'none'",
12004
11983
  "form-action 'none'",
12005
11984
  "frame-ancestors 'none'"
12006
11985
  ].join("; ");
12007
11986
  const SCRIPT_PATH = "/assets/board.js";
12008
11987
  const STYLESHEET_PATH = "/assets/board.css";
11988
+ const FONT_PATH = /^\/assets\/fonts\/([a-z0-9-]+\.woff2)$/;
12009
11989
  /** The document itself. No data in it; the `data:` icon keeps the browser from asking for `/favicon.ico`. */
12010
11990
  const BOARD_PAGE_HTML = `<!doctype html>
12011
11991
  <html lang="en">
@@ -12032,19 +12012,23 @@ function resolveBoardPageDirectory() {
12032
12012
  const candidates = [join(here, "page"), join(here, "..", "..", "dist", "page")];
12033
12013
  return candidates.find((directory) => existsSync(join(directory, "board.js"))) ?? candidates[1];
12034
12014
  }
12035
- /** The page's two files from `directory`, or `null` when the page has not been built there. */
12015
+ /** The page's files from `directory`, or `null` when the page has not been built there. */
12036
12016
  function readBoardPageAssets(directory) {
12037
12017
  const script = join(directory, "board.js");
12038
12018
  const stylesheet = join(directory, "board.css");
12039
12019
  if (!existsSync(script) || !existsSync(stylesheet)) return null;
12020
+ const fontDirectory = join(directory, "fonts");
12021
+ const fonts = /* @__PURE__ */ new Map();
12022
+ if (existsSync(fontDirectory)) for (const name of readdirSync(fontDirectory).filter((file) => FONT_PATH.test(`/assets/fonts/${file}`))) fonts.set(name, readFileSync(join(fontDirectory, name)));
12040
12023
  return {
12041
12024
  script: readFileSync(script, "utf8"),
12042
- stylesheet: readFileSync(stylesheet, "utf8")
12025
+ stylesheet: readFileSync(stylesheet, "utf8"),
12026
+ fonts
12043
12027
  };
12044
12028
  }
12045
12029
  /** Whether a path is one of the page's own. */
12046
12030
  function isBoardPagePath(pathname) {
12047
- return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH;
12031
+ return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH || FONT_PATH.test(pathname);
12048
12032
  }
12049
12033
  /**
12050
12034
  * Answer a page path. With no built page, `/` says how to build it rather than
@@ -12063,6 +12047,31 @@ function sendBoardPage(response, pathname, assets) {
12063
12047
  response.end(body);
12064
12048
  return;
12065
12049
  }
12050
+ const fontName = FONT_PATH.exec(pathname)?.[1];
12051
+ if (fontName !== void 0) {
12052
+ const font = assets.fonts.get(fontName);
12053
+ if (!font) {
12054
+ const body = JSON.stringify({
12055
+ code: "not_found",
12056
+ message: "No such font"
12057
+ });
12058
+ response.writeHead(404, {
12059
+ "content-type": "application/json",
12060
+ "content-length": Buffer.byteLength(body)
12061
+ });
12062
+ response.end(body);
12063
+ return;
12064
+ }
12065
+ response.writeHead(200, {
12066
+ "content-type": "font/woff2",
12067
+ "content-length": font.length,
12068
+ "cache-control": "no-cache",
12069
+ "x-content-type-options": "nosniff",
12070
+ "referrer-policy": "no-referrer"
12071
+ });
12072
+ response.end(font);
12073
+ return;
12074
+ }
12066
12075
  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];
12067
12076
  response.writeHead(200, {
12068
12077
  "content-type": contentType,
@@ -12408,6 +12417,113 @@ function splitPath(path) {
12408
12417
  });
12409
12418
  }
12410
12419
  //#endregion
12420
+ //#region src/cli/metrics-view.ts
12421
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
12422
+ const BOARD_METRICS_FIELDS = [
12423
+ "range",
12424
+ "now",
12425
+ "milestoneId",
12426
+ "columns",
12427
+ "cycleTime",
12428
+ "leadTime",
12429
+ "throughput",
12430
+ "wip",
12431
+ "aging",
12432
+ "timeInColumn",
12433
+ "cumulativeFlow",
12434
+ "finished",
12435
+ "returns",
12436
+ "attempts",
12437
+ "canceled",
12438
+ "agents",
12439
+ "people"
12440
+ ];
12441
+ const LABEL_WIDTH = 16;
12442
+ /** 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. */
12443
+ function formatDuration(seconds) {
12444
+ if (seconds === null) return "—";
12445
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
12446
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
12447
+ return `${Number((seconds / 86400).toFixed(1))}d`;
12448
+ }
12449
+ function percent(share) {
12450
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
12451
+ }
12452
+ function decimal(value) {
12453
+ return value === null ? "—" : String(Number(value.toFixed(1)));
12454
+ }
12455
+ function count(n, noun) {
12456
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
12457
+ }
12458
+ function day(seconds) {
12459
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
12460
+ }
12461
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
12462
+ function block(label, items) {
12463
+ const [first = "—", ...rest] = items;
12464
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
12465
+ }
12466
+ /** Items joined on one line, or none at all. */
12467
+ function oneLine(items) {
12468
+ return items.length === 0 ? [] : [items.join(" · ")];
12469
+ }
12470
+ /**
12471
+ * The metrics as a person reads them in a terminal: the range, then one block
12472
+ * per figure. The cumulative flow is a series per day and has no one-screen
12473
+ * form, so the last line says how to get it.
12474
+ */
12475
+ function describeMetrics(metrics, sprint) {
12476
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
12477
+ const by = metrics.returns.byActorKind;
12478
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
12479
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
12480
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
12481
+ 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");
12482
+ return lines.join("\n");
12483
+ }
12484
+ function describeAgent(agent) {
12485
+ const { runs } = agent;
12486
+ 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`;
12487
+ }
12488
+ function describePerson(person) {
12489
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
12490
+ }
12491
+ const WEEK_SECONDS = 604800;
12492
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
12493
+ function defaultStatsFrom(now) {
12494
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
12495
+ }
12496
+ /**
12497
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
12498
+ *
12499
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
12500
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
12501
+ * win over either end.
12502
+ */
12503
+ function registerStatsCommand(program) {
12504
+ 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) => {
12505
+ await runBoardCommand(options, "read", async (session) => {
12506
+ const now = currentUnixSeconds();
12507
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
12508
+ if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
12509
+ milestoneId: options.sprint,
12510
+ workspaceId: session.workspace.id
12511
+ });
12512
+ const metrics = await session.ops.boardMetrics({
12513
+ workspaceIds: [session.workspace.id],
12514
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
12515
+ to: options.to ?? sprint?.dueDate ?? now,
12516
+ milestoneId: sprint?.id ?? null
12517
+ }, now);
12518
+ return {
12519
+ value: metrics,
12520
+ text: describeMetrics(metrics, sprint),
12521
+ fields: BOARD_METRICS_FIELDS
12522
+ };
12523
+ });
12524
+ });
12525
+ }
12526
+ //#endregion
12411
12527
  //#region src/serve/schemas.ts
12412
12528
  /**
12413
12529
  * The request shapes `kanbo serve` takes: the bodies and queries of the
@@ -12749,6 +12865,14 @@ function createActivityResolver(workspace, personWriterId = null) {
12749
12865
  * a server started for one of them must not reach into the rest.
12750
12866
  */
12751
12867
  const OK = { ok: true };
12868
+ /** Unix seconds in a query string, where everything arrives as text. */
12869
+ const unixSecondsQuery = z.string().regex(/^\d+$/).transform(Number);
12870
+ /** `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. */
12871
+ const metricsQuery = z.object({
12872
+ from: unixSecondsQuery.optional(),
12873
+ to: unixSecondsQuery.optional(),
12874
+ sprint: z.string().min(1).optional()
12875
+ });
12752
12876
  /** How many cards a search answers with when the caller names no limit. */
12753
12877
  const DEFAULT_SEARCH_LIMIT = 20;
12754
12878
  /**
@@ -13044,6 +13168,25 @@ function createIssueRoutes(session, options = {}) {
13044
13168
  return updated;
13045
13169
  }
13046
13170
  }),
13171
+ route({
13172
+ method: "GET",
13173
+ path: "/issues/metrics",
13174
+ query: metricsQuery,
13175
+ handle: async ({ query }) => {
13176
+ const now = currentUnixSeconds();
13177
+ const sprint = query.sprint === void 0 ? null : (await ops.listSprints(workspaceId, now)).find((candidate) => candidate.id === query.sprint) ?? null;
13178
+ if (query.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
13179
+ milestoneId: query.sprint,
13180
+ workspaceId
13181
+ });
13182
+ return await ops.boardMetrics({
13183
+ workspaceIds: [workspaceId],
13184
+ from: query.from ?? sprint?.startDate ?? defaultStatsFrom(now),
13185
+ to: query.to ?? sprint?.dueDate ?? now,
13186
+ milestoneId: sprint?.id ?? null
13187
+ }, now);
13188
+ }
13189
+ }),
13047
13190
  route({
13048
13191
  method: "GET",
13049
13192
  path: "/issues/workspace",
@@ -14037,113 +14180,6 @@ function registerSprintCommands(program) {
14037
14180
  });
14038
14181
  }
14039
14182
  //#endregion
14040
- //#region src/cli/metrics-view.ts
14041
- /** Every field `kanbo stats --json` may name, in the order the value carries them. */
14042
- const BOARD_METRICS_FIELDS = [
14043
- "range",
14044
- "now",
14045
- "milestoneId",
14046
- "columns",
14047
- "cycleTime",
14048
- "leadTime",
14049
- "throughput",
14050
- "wip",
14051
- "aging",
14052
- "timeInColumn",
14053
- "cumulativeFlow",
14054
- "finished",
14055
- "returns",
14056
- "attempts",
14057
- "canceled",
14058
- "agents",
14059
- "people"
14060
- ];
14061
- const LABEL_WIDTH = 16;
14062
- /** 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. */
14063
- function formatDuration(seconds) {
14064
- if (seconds === null) return "—";
14065
- if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
14066
- if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
14067
- return `${Number((seconds / 86400).toFixed(1))}d`;
14068
- }
14069
- function percent(share) {
14070
- return share === null ? "—" : `${Math.round(share * 100)}%`;
14071
- }
14072
- function decimal(value) {
14073
- return value === null ? "—" : String(Number(value.toFixed(1)));
14074
- }
14075
- function count(n, noun) {
14076
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
14077
- }
14078
- function day(seconds) {
14079
- return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
14080
- }
14081
- /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
14082
- function block(label, items) {
14083
- const [first = "—", ...rest] = items;
14084
- return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
14085
- }
14086
- /** Items joined on one line, or none at all. */
14087
- function oneLine(items) {
14088
- return items.length === 0 ? [] : [items.join(" · ")];
14089
- }
14090
- /**
14091
- * The metrics as a person reads them in a terminal: the range, then one block
14092
- * per figure. The cumulative flow is a series per day and has no one-screen
14093
- * form, so the last line says how to get it.
14094
- */
14095
- function describeMetrics(metrics, sprint) {
14096
- const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
14097
- const by = metrics.returns.byActorKind;
14098
- const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
14099
- if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
14100
- if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
14101
- 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");
14102
- return lines.join("\n");
14103
- }
14104
- function describeAgent(agent) {
14105
- const { runs } = agent;
14106
- 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`;
14107
- }
14108
- function describePerson(person) {
14109
- return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
14110
- }
14111
- const WEEK_SECONDS = 604800;
14112
- /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
14113
- function defaultStatsFrom(now) {
14114
- return isoWeekOf(now).start - 11 * WEEK_SECONDS;
14115
- }
14116
- /**
14117
- * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
14118
- *
14119
- * Reading only, so it is anybody's, an agent's shell included. A sprint sets
14120
- * the range to its own dates and keeps its cards; `--from` and `--to` still
14121
- * win over either end.
14122
- */
14123
- function registerStatsCommand(program) {
14124
- 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) => {
14125
- await runBoardCommand(options, "read", async (session) => {
14126
- const now = currentUnixSeconds();
14127
- const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
14128
- if (options.sprint !== void 0 && sprint === null) throw new BoardError("issue_milestone_not_found", {
14129
- milestoneId: options.sprint,
14130
- workspaceId: session.workspace.id
14131
- });
14132
- const metrics = await session.ops.boardMetrics({
14133
- workspaceIds: [session.workspace.id],
14134
- from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
14135
- to: options.to ?? sprint?.dueDate ?? now,
14136
- milestoneId: sprint?.id ?? null
14137
- }, now);
14138
- return {
14139
- value: metrics,
14140
- text: describeMetrics(metrics, sprint),
14141
- fields: BOARD_METRICS_FIELDS
14142
- };
14143
- });
14144
- });
14145
- }
14146
- //#endregion
14147
14183
  //#region src/cli/commands/uninstall.ts
14148
14184
  function registerUninstallCommand(program) {
14149
14185
  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) => {