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.
@@ -5480,11 +5480,28 @@ async function openBoardSession(options, mode, cwd = process.cwd()) {
5480
5480
  asAgent: options.asAgent,
5481
5481
  cwd
5482
5482
  });
5483
- const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
5483
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd).catch((error) => {
5484
+ throw describeEnvironmentOnlyFailure(error, target, options, cwd);
5485
+ });
5484
5486
  const found = findBinding(cwd);
5485
5487
  if (found) rememberProjectQuietly(found.projectDir);
5486
5488
  return session;
5487
5489
  }
5490
+ /**
5491
+ * A folder with no board of its own that only `KANBO_DB_PATH` points at an
5492
+ * app's database: when that database is older than this kanbo, or knows no
5493
+ * project for the folder, the folder is told it has no board, not that "the
5494
+ * app's database" needs updating — the person never chose that database. A
5495
+ * flag or a binding that names the database keeps the app-centred sentence.
5496
+ */
5497
+ function describeEnvironmentOnlyFailure(error, target, options, cwd) {
5498
+ if (!(target.owner !== "kanbo" && !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(cwd) === null) || !(error instanceof CliError)) return error;
5499
+ let reason;
5500
+ 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";
5501
+ 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";
5502
+ else return error;
5503
+ 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.`);
5504
+ }
5488
5505
  async function openFileSession(target, options, mode, cwd) {
5489
5506
  const board = await require_migrate.openBoardDatabase(target.path);
5490
5507
  try {
@@ -6849,7 +6866,7 @@ function readWipLimit(value) {
6849
6866
  * `package.version`. A build carries it as a literal; running from source
6850
6867
  * (tests, `tsx`) reads the package's own `package.json` instead.
6851
6868
  */
6852
- const KANBO_PACKAGE_VERSION = "0.7.0";
6869
+ const KANBO_PACKAGE_VERSION = "0.8.0";
6853
6870
  //#endregion
6854
6871
  //#region src/cli/setup/paths.ts
6855
6872
  /** Where Codex keeps its global files: `$CODEX_HOME`, else `~/.codex`. */
@@ -8187,7 +8204,7 @@ function startUpdateCheck(deps = {}) {
8187
8204
  setTimeout: globalThis.setTimeout,
8188
8205
  clearTimeout: globalThis.clearTimeout
8189
8206
  };
8190
- const current = deps.currentVersion ?? "0.7.0";
8207
+ const current = deps.currentVersion ?? "0.8.0";
8191
8208
  const controller = new AbortController();
8192
8209
  let found = null;
8193
8210
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -9356,26 +9373,6 @@ function assertOwnBoardFile(absolutePath) {
9356
9373
  function notABoardFileMessage(absolutePath) {
9357
9374
  return `${absolutePath} already holds a database of its own. Name another path with --file, or move that file out of the way.`;
9358
9375
  }
9359
- /** The plan as a person reads it before saying yes. */
9360
- function describeInitPlan(plan) {
9361
- const lines = ["kanbo init will:"];
9362
- const columns = plan.columns ? `, with the columns ${plan.columns.map((column) => column.name).join(", ")}` : "";
9363
- if (plan.boardFile) {
9364
- const what = plan.boardFile.exists ? "the board file of this project, brought up to date" : `a board file of this project's own${columns}`;
9365
- lines.push(` ${plan.boardFile.exists ? "update" : "create"} ${relativeTo(plan, plan.boardFile.path)} (${what})`);
9366
- }
9367
- if (plan.target.kind === "postgres" && plan.migrate) lines.push(` create the board's tables in ${maskDatabaseUrl(plan.target.url)}${columns}`);
9368
- lines.push(` write ${BINDING_FILE_PATH} (this project uses board ${plan.workspace.name})`);
9369
- 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)})`);
9370
- if (plan.firstCard) lines.push(` add a first card to To Do: ${plan.firstCard}`);
9371
- for (const note of plan.connect?.notes ?? []) lines.push(`Note: ${note}`);
9372
- lines.push("Nothing else is changed.");
9373
- return lines.join("\n");
9374
- }
9375
- function relativeTo(plan, path) {
9376
- const inside = (0, node_path.relative)(plan.projectDir, path);
9377
- return inside.startsWith("..") ? path : inside;
9378
- }
9379
9376
  /**
9380
9377
  * Carry the plan out: the board first — the file, or the shared database's
9381
9378
  * tables — so a migration that fails leaves no binding pointing at nothing;
@@ -9557,21 +9554,6 @@ async function runInitWizard(context) {
9557
9554
  }
9558
9555
  }
9559
9556
  const plan = await context.plan(projectDir, options);
9560
- if (options.firstCard === void 0 && canAddCard(plan)) {
9561
- const title = (await ui.text({
9562
- message: "Add a first card? (optional — Enter to skip)",
9563
- placeholder: "e.g. Add a README"
9564
- })).trim();
9565
- if (title) {
9566
- options.firstCard = title;
9567
- plan.firstCard = title;
9568
- }
9569
- }
9570
- say(ui, describeInitPlan(plan));
9571
- if (!await ui.confirm({
9572
- message: "Write these changes?",
9573
- initialValue: true
9574
- })) throw new CliError(1, "Nothing was written.");
9575
9557
  return {
9576
9558
  projectDir,
9577
9559
  options,
@@ -9817,10 +9799,6 @@ async function askConnect(ui) {
9817
9799
  }]
9818
9800
  });
9819
9801
  }
9820
- /** A shared board whose tables were not created here may have none yet, so there is nowhere to put a card. */
9821
- function canAddCard(plan) {
9822
- return plan.target.kind !== "postgres" || plan.migrate;
9823
- }
9824
9802
  /** (9) What was done, and what to do next. */
9825
9803
  function printInitOutro(ui, result, applied) {
9826
9804
  const { plan } = result;
@@ -12025,12 +12003,14 @@ const BOARD_PAGE_CSP = [
12025
12003
  "style-src 'self'",
12026
12004
  "connect-src 'self'",
12027
12005
  "img-src 'self' data:",
12006
+ "font-src 'self'",
12028
12007
  "base-uri 'none'",
12029
12008
  "form-action 'none'",
12030
12009
  "frame-ancestors 'none'"
12031
12010
  ].join("; ");
12032
12011
  const SCRIPT_PATH = "/assets/board.js";
12033
12012
  const STYLESHEET_PATH = "/assets/board.css";
12013
+ const FONT_PATH = /^\/assets\/fonts\/([a-z0-9-]+\.woff2)$/;
12034
12014
  /** The document itself. No data in it; the `data:` icon keeps the browser from asking for `/favicon.ico`. */
12035
12015
  const BOARD_PAGE_HTML = `<!doctype html>
12036
12016
  <html lang="en">
@@ -12057,19 +12037,23 @@ function resolveBoardPageDirectory() {
12057
12037
  const candidates = [(0, node_path.join)(here, "page"), (0, node_path.join)(here, "..", "..", "dist", "page")];
12058
12038
  return candidates.find((directory) => (0, node_fs.existsSync)((0, node_path.join)(directory, "board.js"))) ?? candidates[1];
12059
12039
  }
12060
- /** The page's two files from `directory`, or `null` when the page has not been built there. */
12040
+ /** The page's files from `directory`, or `null` when the page has not been built there. */
12061
12041
  function readBoardPageAssets(directory) {
12062
12042
  const script = (0, node_path.join)(directory, "board.js");
12063
12043
  const stylesheet = (0, node_path.join)(directory, "board.css");
12064
12044
  if (!(0, node_fs.existsSync)(script) || !(0, node_fs.existsSync)(stylesheet)) return null;
12045
+ const fontDirectory = (0, node_path.join)(directory, "fonts");
12046
+ const fonts = /* @__PURE__ */ new Map();
12047
+ 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)));
12065
12048
  return {
12066
12049
  script: (0, node_fs.readFileSync)(script, "utf8"),
12067
- stylesheet: (0, node_fs.readFileSync)(stylesheet, "utf8")
12050
+ stylesheet: (0, node_fs.readFileSync)(stylesheet, "utf8"),
12051
+ fonts
12068
12052
  };
12069
12053
  }
12070
12054
  /** Whether a path is one of the page's own. */
12071
12055
  function isBoardPagePath(pathname) {
12072
- return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH;
12056
+ return pathname === "/" || pathname === SCRIPT_PATH || pathname === STYLESHEET_PATH || FONT_PATH.test(pathname);
12073
12057
  }
12074
12058
  /**
12075
12059
  * Answer a page path. With no built page, `/` says how to build it rather than
@@ -12088,6 +12072,31 @@ function sendBoardPage(response, pathname, assets) {
12088
12072
  response.end(body);
12089
12073
  return;
12090
12074
  }
12075
+ const fontName = FONT_PATH.exec(pathname)?.[1];
12076
+ if (fontName !== void 0) {
12077
+ const font = assets.fonts.get(fontName);
12078
+ if (!font) {
12079
+ const body = JSON.stringify({
12080
+ code: "not_found",
12081
+ message: "No such font"
12082
+ });
12083
+ response.writeHead(404, {
12084
+ "content-type": "application/json",
12085
+ "content-length": Buffer.byteLength(body)
12086
+ });
12087
+ response.end(body);
12088
+ return;
12089
+ }
12090
+ response.writeHead(200, {
12091
+ "content-type": "font/woff2",
12092
+ "content-length": font.length,
12093
+ "cache-control": "no-cache",
12094
+ "x-content-type-options": "nosniff",
12095
+ "referrer-policy": "no-referrer"
12096
+ });
12097
+ response.end(font);
12098
+ return;
12099
+ }
12091
12100
  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];
12092
12101
  response.writeHead(200, {
12093
12102
  "content-type": contentType,
@@ -12433,6 +12442,113 @@ function splitPath(path) {
12433
12442
  });
12434
12443
  }
12435
12444
  //#endregion
12445
+ //#region src/cli/metrics-view.ts
12446
+ /** Every field `kanbo stats --json` may name, in the order the value carries them. */
12447
+ const BOARD_METRICS_FIELDS = [
12448
+ "range",
12449
+ "now",
12450
+ "milestoneId",
12451
+ "columns",
12452
+ "cycleTime",
12453
+ "leadTime",
12454
+ "throughput",
12455
+ "wip",
12456
+ "aging",
12457
+ "timeInColumn",
12458
+ "cumulativeFlow",
12459
+ "finished",
12460
+ "returns",
12461
+ "attempts",
12462
+ "canceled",
12463
+ "agents",
12464
+ "people"
12465
+ ];
12466
+ const LABEL_WIDTH = 16;
12467
+ /** 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. */
12468
+ function formatDuration(seconds) {
12469
+ if (seconds === null) return "—";
12470
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
12471
+ if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
12472
+ return `${Number((seconds / 86400).toFixed(1))}d`;
12473
+ }
12474
+ function percent(share) {
12475
+ return share === null ? "—" : `${Math.round(share * 100)}%`;
12476
+ }
12477
+ function decimal(value) {
12478
+ return value === null ? "—" : String(Number(value.toFixed(1)));
12479
+ }
12480
+ function count(n, noun) {
12481
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
12482
+ }
12483
+ function day(seconds) {
12484
+ return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
12485
+ }
12486
+ /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
12487
+ function block(label, items) {
12488
+ const [first = "—", ...rest] = items;
12489
+ return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
12490
+ }
12491
+ /** Items joined on one line, or none at all. */
12492
+ function oneLine(items) {
12493
+ return items.length === 0 ? [] : [items.join(" · ")];
12494
+ }
12495
+ /**
12496
+ * The metrics as a person reads them in a terminal: the range, then one block
12497
+ * per figure. The cumulative flow is a series per day and has no one-screen
12498
+ * form, so the last line says how to get it.
12499
+ */
12500
+ function describeMetrics(metrics, sprint) {
12501
+ const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
12502
+ const by = metrics.returns.byActorKind;
12503
+ const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
12504
+ if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
12505
+ if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
12506
+ 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");
12507
+ return lines.join("\n");
12508
+ }
12509
+ function describeAgent(agent) {
12510
+ const { runs } = agent;
12511
+ 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`;
12512
+ }
12513
+ function describePerson(person) {
12514
+ return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
12515
+ }
12516
+ const WEEK_SECONDS = 604800;
12517
+ /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
12518
+ function defaultStatsFrom(now) {
12519
+ return isoWeekOf(now).start - 11 * WEEK_SECONDS;
12520
+ }
12521
+ /**
12522
+ * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
12523
+ *
12524
+ * Reading only, so it is anybody's, an agent's shell included. A sprint sets
12525
+ * the range to its own dates and keeps its cards; `--from` and `--to` still
12526
+ * win over either end.
12527
+ */
12528
+ function registerStatsCommand(program) {
12529
+ 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) => {
12530
+ await runBoardCommand(options, "read", async (session) => {
12531
+ const now = currentUnixSeconds();
12532
+ const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
12533
+ if (options.sprint !== void 0 && sprint === null) throw new require_migrations_path.BoardError("issue_milestone_not_found", {
12534
+ milestoneId: options.sprint,
12535
+ workspaceId: session.workspace.id
12536
+ });
12537
+ const metrics = await session.ops.boardMetrics({
12538
+ workspaceIds: [session.workspace.id],
12539
+ from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
12540
+ to: options.to ?? sprint?.dueDate ?? now,
12541
+ milestoneId: sprint?.id ?? null
12542
+ }, now);
12543
+ return {
12544
+ value: metrics,
12545
+ text: describeMetrics(metrics, sprint),
12546
+ fields: BOARD_METRICS_FIELDS
12547
+ };
12548
+ });
12549
+ });
12550
+ }
12551
+ //#endregion
12436
12552
  //#region src/serve/schemas.ts
12437
12553
  /**
12438
12554
  * The request shapes `kanbo serve` takes: the bodies and queries of the
@@ -12774,6 +12890,14 @@ function createActivityResolver(workspace, personWriterId = null) {
12774
12890
  * a server started for one of them must not reach into the rest.
12775
12891
  */
12776
12892
  const OK = { ok: true };
12893
+ /** Unix seconds in a query string, where everything arrives as text. */
12894
+ const unixSecondsQuery = zod.z.string().regex(/^\d+$/).transform(Number);
12895
+ /** `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. */
12896
+ const metricsQuery = zod.z.object({
12897
+ from: unixSecondsQuery.optional(),
12898
+ to: unixSecondsQuery.optional(),
12899
+ sprint: zod.z.string().min(1).optional()
12900
+ });
12777
12901
  /** How many cards a search answers with when the caller names no limit. */
12778
12902
  const DEFAULT_SEARCH_LIMIT = 20;
12779
12903
  /**
@@ -13069,6 +13193,25 @@ function createIssueRoutes(session, options = {}) {
13069
13193
  return updated;
13070
13194
  }
13071
13195
  }),
13196
+ route({
13197
+ method: "GET",
13198
+ path: "/issues/metrics",
13199
+ query: metricsQuery,
13200
+ handle: async ({ query }) => {
13201
+ const now = currentUnixSeconds();
13202
+ const sprint = query.sprint === void 0 ? null : (await ops.listSprints(workspaceId, now)).find((candidate) => candidate.id === query.sprint) ?? null;
13203
+ if (query.sprint !== void 0 && sprint === null) throw new require_migrations_path.BoardError("issue_milestone_not_found", {
13204
+ milestoneId: query.sprint,
13205
+ workspaceId
13206
+ });
13207
+ return await ops.boardMetrics({
13208
+ workspaceIds: [workspaceId],
13209
+ from: query.from ?? sprint?.startDate ?? defaultStatsFrom(now),
13210
+ to: query.to ?? sprint?.dueDate ?? now,
13211
+ milestoneId: sprint?.id ?? null
13212
+ }, now);
13213
+ }
13214
+ }),
13072
13215
  route({
13073
13216
  method: "GET",
13074
13217
  path: "/issues/workspace",
@@ -14062,113 +14205,6 @@ function registerSprintCommands(program) {
14062
14205
  });
14063
14206
  }
14064
14207
  //#endregion
14065
- //#region src/cli/metrics-view.ts
14066
- /** Every field `kanbo stats --json` may name, in the order the value carries them. */
14067
- const BOARD_METRICS_FIELDS = [
14068
- "range",
14069
- "now",
14070
- "milestoneId",
14071
- "columns",
14072
- "cycleTime",
14073
- "leadTime",
14074
- "throughput",
14075
- "wip",
14076
- "aging",
14077
- "timeInColumn",
14078
- "cumulativeFlow",
14079
- "finished",
14080
- "returns",
14081
- "attempts",
14082
- "canceled",
14083
- "agents",
14084
- "people"
14085
- ];
14086
- const LABEL_WIDTH = 16;
14087
- /** 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. */
14088
- function formatDuration(seconds) {
14089
- if (seconds === null) return "—";
14090
- if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
14091
- if (seconds < 86400) return `${Number((seconds / 3600).toFixed(1))}h`;
14092
- return `${Number((seconds / 86400).toFixed(1))}d`;
14093
- }
14094
- function percent(share) {
14095
- return share === null ? "—" : `${Math.round(share * 100)}%`;
14096
- }
14097
- function decimal(value) {
14098
- return value === null ? "—" : String(Number(value.toFixed(1)));
14099
- }
14100
- function count(n, noun) {
14101
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
14102
- }
14103
- function day(seconds) {
14104
- return (/* @__PURE__ */ new Date(seconds * 1e3)).toISOString().slice(0, 10);
14105
- }
14106
- /** A labelled block: the first item beside the label, the rest under it; a dash when there is nothing. */
14107
- function block(label, items) {
14108
- const [first = "—", ...rest] = items;
14109
- return [`${label.padEnd(LABEL_WIDTH)}${first}`, ...rest.map((item) => `${" ".repeat(LABEL_WIDTH)}${item}`)];
14110
- }
14111
- /** Items joined on one line, or none at all. */
14112
- function oneLine(items) {
14113
- return items.length === 0 ? [] : [items.join(" · ")];
14114
- }
14115
- /**
14116
- * The metrics as a person reads them in a terminal: the range, then one block
14117
- * per figure. The cumulative flow is a series per day and has no one-screen
14118
- * form, so the last line says how to get it.
14119
- */
14120
- function describeMetrics(metrics, sprint) {
14121
- const column = (slug) => sanitizeTerminalText(metrics.columns.find((candidate) => candidate.slug === slug)?.name ?? slug);
14122
- const by = metrics.returns.byActorKind;
14123
- const lines = [`${day(metrics.range.from)} → ${day(metrics.range.to)}`];
14124
- if (sprint) lines.push(`${sanitizeTerminalText(sprint.title)}: ${sprint.cards.open + sprint.cards.done} planned · ${sprint.cards.done} done`);
14125
- if (metrics.range.from > metrics.range.to) return [...lines, "Nothing measured yet: the range starts after now."].join("\n");
14126
- 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");
14127
- return lines.join("\n");
14128
- }
14129
- function describeAgent(agent) {
14130
- const { runs } = agent;
14131
- 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`;
14132
- }
14133
- function describePerson(person) {
14134
- return `${sanitizeTerminalText(person.id ?? "unnamed")}: ${person.created} created · ${person.approvals} approved · ${person.returns} returned · ${person.moves} moved`;
14135
- }
14136
- const WEEK_SECONDS = 604800;
14137
- /** The Monday that makes the range `DEFAULT_STATS_WEEKS` ISO weeks long, ending with the week of `now`. */
14138
- function defaultStatsFrom(now) {
14139
- return isoWeekOf(now).start - 11 * WEEK_SECONDS;
14140
- }
14141
- /**
14142
- * `kanbo stats` — how work flows on this board (spec 2026-11-05, Part 4).
14143
- *
14144
- * Reading only, so it is anybody's, an agent's shell included. A sprint sets
14145
- * the range to its own dates and keeps its cards; `--from` and `--to` still
14146
- * win over either end.
14147
- */
14148
- function registerStatsCommand(program) {
14149
- 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) => {
14150
- await runBoardCommand(options, "read", async (session) => {
14151
- const now = currentUnixSeconds();
14152
- const sprint = options.sprint === void 0 ? null : (await session.ops.listSprints(session.workspace.id, now)).find((candidate) => candidate.id === options.sprint) ?? null;
14153
- if (options.sprint !== void 0 && sprint === null) throw new require_migrations_path.BoardError("issue_milestone_not_found", {
14154
- milestoneId: options.sprint,
14155
- workspaceId: session.workspace.id
14156
- });
14157
- const metrics = await session.ops.boardMetrics({
14158
- workspaceIds: [session.workspace.id],
14159
- from: options.from ?? sprint?.startDate ?? defaultStatsFrom(now),
14160
- to: options.to ?? sprint?.dueDate ?? now,
14161
- milestoneId: sprint?.id ?? null
14162
- }, now);
14163
- return {
14164
- value: metrics,
14165
- text: describeMetrics(metrics, sprint),
14166
- fields: BOARD_METRICS_FIELDS
14167
- };
14168
- });
14169
- });
14170
- }
14171
- //#endregion
14172
14208
  //#region src/cli/commands/uninstall.ts
14173
14209
  function registerUninstallCommand(program) {
14174
14210
  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) => {