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 +19 -0
- package/README.md +1 -1
- package/dist/cli-main.cjs +188 -152
- package/dist/lib/index.cjs +1 -1
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/mcp/index.cjs +1 -1
- package/dist/lib/mcp/index.mjs +1 -1
- package/dist/lib/{tool-names-CjIo1rdF.cjs → tool-names-BOUJas7m.cjs} +188 -152
- package/dist/lib/{tool-names-CGn-8BYp.mjs → tool-names-CrvHZ-ON.mjs} +189 -153
- package/dist/page/board.css +2058 -842
- package/dist/page/board.js +5 -2
- package/dist/page/fonts/LICENSE +93 -0
- package/dist/page/fonts/geist-cyrillic-wght-normal.woff2 +0 -0
- package/dist/page/fonts/geist-latin-ext-wght-normal.woff2 +0 -0
- package/dist/page/fonts/geist-latin-wght-normal.woff2 +0 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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
|
|
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) => {
|