Menu
AkurAI-Build
publicLatest change 30e6b8c347048d64c2c048dfd05a8ded219d37f6 - Host authenticated Git repositories over Smart HTTP with repo host/sync CLI by Ólafur Búi Ólafsson
#!/usr/bin/env node
import { randomBytes } from "node:crypto";
import {
appendFile,
mkdir,
open,
readFile,
readdir,
rename,
unlink,
writeFile,
} from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(
process.env.KANBAN_ROOT ??
join(dirname(fileURLToPath(import.meta.url)), ".."),
);
const BOARD_PATH = join(ROOT, "board.json");
const STATE_DIR = join(ROOT, "state");
const EVENTS_PATH = join(STATE_DIR, "events.jsonl");
const LOCK_PATH = join(STATE_DIR, "write.lock");
const HANDOFFS_DIR = join(ROOT, "handoffs");
const PRIORITY_ORDER = ["P0", "P1", "P2", "P3"];
const EXPECTED_COLUMNS = [
"inbox",
"triage",
"discovery",
"ready",
"in-progress",
"review",
"verification",
"release-ready",
"done",
];
const EXPECTED_DIRECTORIES = [
"00-inbox",
"10-triage",
"20-discovery",
"30-ready",
"40-in-progress",
"50-review",
"60-verification",
"70-release-ready",
"80-done",
];
const EXPECTED_TRANSITIONS = {
inbox: ["triage"],
triage: ["inbox", "discovery", "ready", "done"],
discovery: ["triage", "ready", "done"],
ready: ["triage", "discovery", "in-progress", "done"],
"in-progress": ["discovery", "review"],
review: ["in-progress", "verification"],
verification: ["in-progress", "review", "release-ready"],
"release-ready": ["in-progress", "verification", "done"],
done: ["triage"],
};
const RESOLUTIONS = new Set([
"delivered",
"verified-existing",
"cancelled",
"duplicate",
"not-planned",
"superseded",
]);
const NON_DELIVERY_RESOLUTIONS = new Set([
"verified-existing",
"cancelled",
"duplicate",
"not-planned",
"superseded",
]);
const SATISFIED_DEPENDENCY_RESOLUTIONS = new Set([
"delivered",
"verified-existing",
"superseded",
]);
const STRUCTURED_FIELDS = new Set(["labels", "dependencies"]);
const EVIDENCE_STAGES = new Set([
"implementation",
"review",
"verification",
"release",
"resolution",
]);
const EVIDENCE_STAGE_STATUSES = {
implementation: ["in-progress"],
review: ["review"],
verification: ["verification"],
release: ["release-ready"],
resolution: ["triage", "discovery", "ready"],
};
const REQUIRED_EVIDENCE_BY_STATUS = {
review: "implementation",
verification: "review",
"release-ready": "verification",
};
const BOOLEAN_FIELDS = new Set(["blocked"]);
const CARD_FIELDS = [
"schemaVersion",
"id",
"title",
"type",
"priority",
"status",
"blocked",
"blockReason",
"owner",
"createdAt",
"updatedAt",
"statusEnteredAt",
"startedAt",
"completedAt",
"dueAt",
"estimate",
"labels",
"dependencies",
"spec",
"branch",
"issue",
"pr",
"evidence",
"handoff",
"resolution",
];
const SETTABLE_FIELDS = new Set([
"type",
"priority",
"blocked",
"blockReason",
"dueAt",
"estimate",
"labels",
"dependencies",
"spec",
"branch",
"issue",
"pr",
"resolution",
]);
function writeLine(stream, value) {
const text = String(value);
stream.write(text.endsWith("\n") ? text : `${text}\n`);
}
const print = (value) => writeLine(process.stdout, value);
const warn = (value) => writeLine(process.stderr, value);
function parseArguments(argv) {
const positional = [];
const options = {};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith("--")) {
positional.push(value);
continue;
}
const key = value.slice(2);
const next = argv[index + 1];
if (next !== undefined && !next.startsWith("--")) {
options[key] = next;
index += 1;
} else {
options[key] = true;
}
}
return { positional, options };
}
function requireOption(options, key) {
const value = options[key];
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`--${key} is required`);
}
return value.trim();
}
function isSafeText(value, maxLength) {
return (
typeof value === "string" &&
value.length > 0 &&
value.length <= maxLength &&
!/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)
);
}
function safeText(value, label, maxLength) {
if (!isSafeText(value, maxLength)) {
throw new Error(
`${label} must be 1-${maxLength} characters without control or format characters`,
);
}
return value;
}
function safeOption(options, key, maxLength) {
return safeText(requireOption(options, key), `--${key}`, maxLength);
}
function now() {
return new Date().toISOString();
}
function createId() {
const stamp = now()
.replace(/[-:TZ.]/g, "")
.slice(0, 14);
return `KB-${stamp}-${randomBytes(2).toString("hex").toUpperCase()}`;
}
async function loadBoard() {
let board;
try {
board = JSON.parse(await readFile(BOARD_PATH, "utf8"));
} catch (error) {
throw new Error(`cannot read board.json (${error.message})`);
}
if (board.schemaVersion !== 1 || !Array.isArray(board.columns)) {
throw new Error("unsupported .kanban/board.json schema");
}
if (board.columns.length !== 9) {
throw new Error("board.json must define exactly nine columns");
}
const ids = board.columns.map((column) => column.id);
if (ids.some((id, index) => id !== EXPECTED_COLUMNS[index])) {
throw new Error(
`board.json columns must be ordered: ${EXPECTED_COLUMNS.join(", ")}`,
);
}
const directories = board.columns.map((column) => column.directory);
if (
directories.some(
(directory, index) => directory !== EXPECTED_DIRECTORIES[index],
)
) {
throw new Error(
`board.json directories must be ordered: ${EXPECTED_DIRECTORIES.join(", ")}`,
);
}
if (new Set(ids).size !== ids.length)
throw new Error("board.json has duplicate column ids");
if (new Set(directories).size !== directories.length) {
throw new Error("board.json has duplicate column directories");
}
for (const column of board.columns) {
if (!isSafeText(column.id, 64) || !isSafeText(column.directory, 64)) {
throw new Error("board.json has an invalid column id or directory");
}
if (
column.wipLimit !== null &&
(!Number.isSafeInteger(column.wipLimit) || column.wipLimit < 1)
) {
throw new Error(`${column.id}: invalid WIP limit`);
}
}
for (const status of [board.commitmentStatus, board.finishStatus]) {
if (!ids.includes(status))
throw new Error(`board.json references unknown status: ${status}`);
}
for (const id of ids) {
if (!Array.isArray(board.transitions?.[id])) {
throw new Error(`board.json is missing transitions for ${id}`);
}
if (
JSON.stringify(board.transitions[id]) !==
JSON.stringify(EXPECTED_TRANSITIONS[id])
) {
throw new Error(`board.json has an invalid transition policy for ${id}`);
}
}
for (const [source, destinations] of Object.entries(
board.transitions ?? {},
)) {
if (!ids.includes(source) || !Array.isArray(destinations)) {
throw new Error(`board.json has invalid transition source: ${source}`);
}
for (const destination of destinations) {
if (!ids.includes(destination)) {
throw new Error(
`board.json has invalid transition ${source} -> ${destination}`,
);
}
}
}
const expectation = board.serviceLevelExpectation;
if (
!expectation ||
expectation.from !== board.commitmentStatus ||
expectation.to !== board.finishStatus ||
typeof expectation.probability !== "number" ||
expectation.probability <= 0 ||
expectation.probability > 1 ||
!Number.isSafeInteger(expectation.days) ||
expectation.days < 1
) {
throw new Error("board.json has an invalid serviceLevelExpectation");
}
return board;
}
function parseDocument(text, path) {
const normalized = text.replaceAll("\r\n", "\n");
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) throw new Error(`${path}: missing JSON front matter`);
let metadata;
try {
metadata = JSON.parse(match[1]);
} catch (error) {
throw new Error(`${path}: invalid JSON front matter (${error.message})`);
}
return { metadata, body: match[2].trimEnd() };
}
function serializeDocument(metadata, body) {
return `---\n${JSON.stringify(metadata, null, 2)}\n---\n${body.trimEnd()}\n`;
}
async function atomicWrite(path, contents) {
await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.tmp-${process.pid}-${randomBytes(3).toString("hex")}`;
try {
await writeFile(temporary, contents, { encoding: "utf8", mode: 0o644 });
await rename(temporary, path);
} catch (error) {
await unlink(temporary).catch(() => {});
throw error;
}
}
async function withWriteLock(action) {
await mkdir(STATE_DIR, { recursive: true });
let handle;
try {
handle = await open(LOCK_PATH, "wx", 0o600);
await handle.writeFile(
JSON.stringify({ pid: process.pid, createdAt: now() }) + "\n",
);
} catch (error) {
if (handle) {
await handle.close().catch(() => {});
await unlink(LOCK_PATH).catch(() => {});
}
if (error?.code === "EEXIST") {
throw new Error(
"board is locked by another writer; inspect .kanban/state/write.lock and retry",
);
}
throw error;
}
try {
return await action();
} finally {
try {
await handle.close();
} finally {
await unlink(LOCK_PATH).catch(() => {});
}
}
}
async function appendEvent(event) {
await appendFile(
EVENTS_PATH,
JSON.stringify({ schemaVersion: 1, at: now(), ...event }) + "\n",
"utf8",
);
}
function columnFor(board, status) {
const column = board.columns.find((candidate) => candidate.id === status);
if (!column) throw new Error(`unknown status: ${status}`);
return column;
}
async function loadCards(board) {
const cards = [];
const columnsRoot = join(ROOT, "columns");
const rootEntries = await readdir(columnsRoot, { withFileTypes: true });
const directories = rootEntries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const expected = [...EXPECTED_DIRECTORIES].sort();
if (
rootEntries.some((entry) => !entry.isDirectory()) ||
JSON.stringify(directories) !== JSON.stringify(expected)
) {
throw new Error(
`columns directory must contain exactly: ${EXPECTED_DIRECTORIES.join(", ")}`,
);
}
for (const column of board.columns) {
const directory = join(ROOT, "columns", column.directory);
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (
!entry.isFile() ||
!entry.name.endsWith(".md") ||
entry.name === "README.md"
)
continue;
const path = join(directory, entry.name);
const document = parseDocument(await readFile(path, "utf8"), path);
cards.push({ ...document, path, column });
}
}
return cards;
}
function findCard(cards, id) {
const card = cards.find(
(candidate) => candidate.metadata.id.toLowerCase() === id.toLowerCase(),
);
if (!card) throw new Error(`task not found: ${id}`);
return card;
}
function taskBody(title) {
return `<!-- markdownlint-disable MD025 -- JSON front matter is not a heading. -->
# ${title}
## Objective
Describe the user or project outcome and why it matters.
## Context
Link the canonical issue, specification, decision, or incident. Record constraints and non-goals.
## Acceptance criteria
- [ ] State observable, testable outcomes.
## Boundaries
State always-do, ask-first, and never-do boundaries for this task.
## Implementation notes
Keep this section current; do not turn it into a transcript.
## Verification
List exact commands, checks, URLs, screenshots, or artifacts required for completion.
## Handoff notes
Record only information the next owner cannot reconstruct cheaply.`;
}
function handoffBody(task, summary) {
return `# Handoff: ${task.metadata.id} — ${task.metadata.title}
## Objective
${summary}
## Current state
Describe what is complete and the task's present status.
## Changes and files
List changed paths, branches, commits, or external artifacts.
## Verification evidence
List commands and results already obtained. Never claim checks that were not run.
## Remaining work
List bounded next steps in execution order.
## Risks and decisions
Record blockers, assumptions, rejected alternatives, and approvals still required.
## Exact next action
Give the receiving agent one deterministic first action.
## Stop conditions
State when the receiver must stop for human input or external action.`;
}
async function saveCard(card, metadata = card.metadata, body = card.body) {
await atomicWrite(card.path, serializeDocument(metadata, body));
}
function cardSummary(card) {
const metadata = card.metadata;
return {
id: metadata.id,
title: metadata.title,
status: metadata.status,
priority: metadata.priority,
type: metadata.type,
owner: metadata.owner,
blocked: metadata.blocked,
updatedAt: metadata.updatedAt,
};
}
function sortCards(left, right) {
const priority =
PRIORITY_ORDER.indexOf(left.metadata.priority) -
PRIORITY_ORDER.indexOf(right.metadata.priority);
return (
priority || left.metadata.createdAt.localeCompare(right.metadata.createdAt)
);
}
function printCards(cards, json) {
const summaries = cards.map(cardSummary);
if (json) {
print(JSON.stringify(summaries, null, 2));
return;
}
if (summaries.length === 0) {
print("No tasks.");
return;
}
for (const task of summaries) {
print(
`${task.id}\t${task.priority}\t${task.status}\t${task.owner ?? "unowned"}\t${task.blocked ? "BLOCKED\t" : ""}${task.title}`,
);
}
}
function checkWip(board, cards, destination, currentId) {
const column = columnFor(board, destination);
if (column.wipLimit === null) return;
const count = cards.filter(
(card) =>
card.metadata.status === destination && card.metadata.id !== currentId,
).length;
if (count >= column.wipLimit) {
throw new Error(
`${column.title} WIP limit ${column.wipLimit} reached; finish or unblock work before pulling another task`,
);
}
}
function section(body, heading) {
const match = body.match(
new RegExp(`(?:^|\\n)## ${heading}\\n([\\s\\S]*?)(?=\\n## |$)`, "i"),
);
return match?.[1]?.trim() ?? "";
}
function hasEvidence(metadata, stage) {
return (
Array.isArray(metadata.evidence) &&
metadata.evidence.some(
(entry) =>
typeof entry === "object" &&
entry !== null &&
entry.stage === stage &&
isSafeText(entry.result, 2000) &&
isSafeText(entry.agent, 64) &&
isIsoTimestamp(entry.at) &&
(!EVIDENCE_STAGE_STATUSES[stage].includes(metadata.status) ||
(isIsoTimestamp(metadata.statusEnteredAt) &&
Date.parse(entry.at) >= Date.parse(metadata.statusEnteredAt))),
)
);
}
function isIsoTimestamp(value) {
if (typeof value !== "string" || Number.isNaN(Date.parse(value)))
return false;
return new Date(value).toISOString() === value;
}
function isHandoffPath(path) {
return path.startsWith(`${HANDOFFS_DIR}${sep}`);
}
function checkHandoffMetadata(handoff) {
const metadata = handoff.metadata;
if (
metadata.schemaVersion !== 1 ||
!/^KB-\d{14}-[A-F0-9]{4}$/.test(metadata.taskId) ||
!isSafeText(metadata.from, 64) ||
!isSafeText(metadata.to, 64) ||
!isIsoTimestamp(metadata.createdAt)
) {
throw new Error("handoff metadata is invalid");
}
if (metadata.status === "pending") {
if (metadata.acceptedAt !== null) {
throw new Error("pending handoff must not have acceptedAt");
}
} else if (metadata.status === "accepted") {
if (
!isIsoTimestamp(metadata.acceptedAt) ||
Date.parse(metadata.acceptedAt) < Date.parse(metadata.createdAt)
) {
throw new Error("accepted handoff timestamp is invalid");
}
} else {
throw new Error(`invalid handoff status ${metadata.status}`);
}
}
function checkHandoffDefinition(handoff) {
const requirements = [
["Objective", "State the bounded outcome"],
["Current state", "Describe what is complete"],
["Changes and files", "List changed paths"],
["Verification evidence", "List commands and results already obtained"],
["Remaining work", "List bounded next steps"],
["Risks and decisions", "Record blockers"],
["Exact next action", "Give the receiving agent"],
["Stop conditions", "State when the receiver"],
];
for (const [heading, placeholder] of requirements) {
const content = section(handoff.body, heading);
if (content.length < 5 || content.includes(placeholder)) {
throw new Error(`handoff requires a completed ${heading} section`);
}
}
}
function checkHandoffIntegrity(card, handoff) {
checkHandoffMetadata(handoff);
if (handoff.metadata.status !== "pending")
throw new Error("handoff is not pending");
if (handoff.metadata.taskId !== card.metadata.id) {
throw new Error("handoff taskId does not match task");
}
if (!card.metadata.owner || handoff.metadata.from !== card.metadata.owner) {
throw new Error("handoff sender does not match current owner");
}
if (!isSafeText(handoff.metadata.to, 64))
throw new Error("handoff has invalid receiver");
checkHandoffDefinition(handoff);
}
function dependenciesSatisfied(card, cards) {
return (card.metadata.dependencies ?? []).every((dependency) => {
const match = cards.find(
(candidate) => candidate.metadata.id === dependency,
);
return (
match?.metadata.status === "done" &&
SATISFIED_DEPENDENCY_RESOLUTIONS.has(match.metadata.resolution)
);
});
}
function checkDependenciesDone(card, cards) {
if (!dependenciesSatisfied(card, cards)) {
const dependency = (card.metadata.dependencies ?? []).find((candidate) => {
const match = cards.find(
(cardCandidate) => cardCandidate.metadata.id === candidate,
);
return (
match?.metadata.status !== "done" ||
!SATISFIED_DEPENDENCY_RESOLUTIONS.has(match.metadata.resolution)
);
});
throw new Error(`dependency is not satisfied: ${dependency}`);
}
}
function requiredEvidenceStage(metadata) {
if (metadata.status !== "done") {
return REQUIRED_EVIDENCE_BY_STATUS[metadata.status] ?? null;
}
return metadata.resolution === "delivered" ? "verification" : "resolution";
}
function checkReadyDefinition(card, cards) {
const requirements = [
["Objective", "Describe the user or project outcome"],
["Context", "Link the canonical issue"],
["Boundaries", "State always-do, ask-first, and never-do boundaries"],
["Verification", "List exact commands"],
];
for (const [heading, placeholder] of requirements) {
const content = section(card.body, heading);
if (content.length < 10 || content.includes(placeholder)) {
throw new Error(`Ready requires a completed ${heading} section`);
}
}
const acceptance = section(card.body, "Acceptance criteria");
if (
!/- \[[ xX]\] .+/.test(acceptance) ||
acceptance.includes("State observable")
) {
throw new Error("Ready requires concrete acceptance criteria");
}
checkDependenciesDone(card, cards);
}
function checkEntryGate(card, destination, cards) {
const metadata = card.metadata;
if (destination === "ready") checkReadyDefinition(card, cards);
if (
["in-progress", "review", "verification", "release-ready", "done"].includes(
destination,
)
) {
checkDependenciesDone(card, cards);
}
if (
["in-progress", "review", "verification", "release-ready"].includes(
destination,
)
) {
if (!metadata.owner) throw new Error(`${destination} requires an owner`);
if (metadata.blocked)
throw new Error(`blocked task cannot enter ${destination}`);
}
let requiredEvidence = REQUIRED_EVIDENCE_BY_STATUS[destination];
if (destination === "done") {
requiredEvidence =
metadata.resolution === "delivered" ? "verification" : "resolution";
}
if (requiredEvidence && !hasEvidence(metadata, requiredEvidence)) {
throw new Error(`${destination} requires ${requiredEvidence} evidence`);
}
if (destination === "done") {
if (metadata.blocked) throw new Error("blocked task cannot enter done");
if (!RESOLUTIONS.has(metadata.resolution)) {
throw new Error("done requires a supported resolution");
}
if (
metadata.resolution === "delivered" &&
card.metadata.status !== "release-ready"
) {
throw new Error("delivered work must enter Done from Release Ready");
}
if (
card.metadata.status !== "release-ready" &&
!NON_DELIVERY_RESOLUTIONS.has(metadata.resolution)
) {
throw new Error(
"upstream Done transitions require a non-delivery resolution",
);
}
}
}
function actingAgent(card, options) {
const agent = safeOption(options, "agent", 64);
if (
card.metadata.owner &&
card.metadata.startedAt &&
card.metadata.owner !== agent
) {
throw new Error(
`task is owned by ${card.metadata.owner}; use a handoff to transfer ownership`,
);
}
return agent;
}
function currentEvidenceAfterMove(board, card, destination) {
const sourceIndex = board.columns.findIndex(
(column) => column.id === card.metadata.status,
);
const destinationIndex = board.columns.findIndex(
(column) => column.id === destination,
);
const evidence = Array.isArray(card.metadata.evidence)
? card.metadata.evidence
: [];
if (destinationIndex >= sourceIndex) return evidence;
const retainedStages =
{
review: ["implementation"],
verification: ["implementation", "review"],
"release-ready": ["implementation", "review", "verification"],
}[destination] ?? [];
return evidence.filter((entry) => retainedStages.includes(entry?.stage));
}
async function moveCard(board, cards, card, destination, options = {}) {
const source = card.metadata.status;
if (source === destination) return card;
columnFor(board, destination);
if (source === "ready" && destination === "in-progress" && !options.claim) {
throw new Error("use claim to move Ready work into In Progress");
}
if (
source === "release-ready" &&
destination === "done" &&
!options.approval
) {
throw new Error("Release Ready -> Done requires --approval");
}
if (source === "done" && destination !== "done") {
const readyIndex = board.columns.findIndex(
(column) => column.id === "ready",
);
const dependents = cards.filter(
(candidate) =>
candidate.metadata.dependencies?.includes(card.metadata.id) &&
board.columns.findIndex(
(column) => column.id === candidate.metadata.status,
) >= readyIndex,
);
if (dependents.length > 0) {
throw new Error(
`cannot reopen; Ready-or-later dependents exist: ${dependents
.map((candidate) => candidate.metadata.id)
.join(", ")}`,
);
}
}
const allowed = board.transitions[source] ?? [];
if (options.force) {
if (!options.reason) throw new Error("--force requires --reason");
if (card.metadata.priority !== "P0") {
throw new Error("forced transitions are limited to P0 work");
}
const commitmentIndex = board.columns.findIndex(
(column) => column.id === board.commitmentStatus,
);
const sourceIndex = board.columns.findIndex(
(column) => column.id === source,
);
const destinationIndex = board.columns.findIndex(
(column) => column.id === destination,
);
if (sourceIndex >= commitmentIndex || destinationIndex >= commitmentIndex) {
throw new Error("forced transitions are limited to upstream queues");
}
}
if (!allowed.includes(destination) && !options.force) {
throw new Error(`transition ${source} -> ${destination} is not allowed`);
}
checkWip(board, cards, destination, card.metadata.id);
checkEntryGate(card, destination, cards);
const timestamp = now();
const metadata = {
...card.metadata,
status: destination,
statusEnteredAt: timestamp,
evidence: currentEvidenceAfterMove(board, card, destination),
updatedAt: timestamp,
};
if (destination === board.commitmentStatus && !metadata.startedAt) {
metadata.startedAt = timestamp;
}
if (destination === board.finishStatus) metadata.completedAt = timestamp;
if (source === board.finishStatus && destination !== board.finishStatus) {
metadata.startedAt = null;
metadata.completedAt = null;
metadata.owner = null;
metadata.evidence = [];
metadata.resolution = null;
}
await saveCard(card, metadata);
const destinationPath = join(
ROOT,
"columns",
columnFor(board, destination).directory,
`${metadata.id}.md`,
);
await rename(card.path, destinationPath);
card.path = destinationPath;
card.metadata = metadata;
return card;
}
async function commandNew(board, options) {
const title = safeOption(options, "title", 200);
const type = typeof options.type === "string" ? options.type : "chore";
const priority =
typeof options.priority === "string" ? options.priority : "P2";
if (!board.types.includes(type)) throw new Error(`unsupported type: ${type}`);
if (!PRIORITY_ORDER.includes(priority))
throw new Error(`unsupported priority: ${priority}`);
const timestamp = now();
const id = createId();
const metadata = {
schemaVersion: 1,
id,
title,
type,
priority,
status: "inbox",
blocked: false,
blockReason: null,
owner:
typeof options.owner === "string"
? safeText(options.owner, "--owner", 64)
: null,
createdAt: timestamp,
updatedAt: timestamp,
statusEnteredAt: timestamp,
startedAt: null,
completedAt: null,
dueAt: null,
estimate: null,
labels: [],
dependencies: [],
spec: null,
branch: null,
issue: null,
pr: null,
evidence: [],
handoff: null,
resolution: null,
};
const path = join(ROOT, "columns", "00-inbox", `${id}.md`);
await atomicWrite(path, serializeDocument(metadata, taskBody(title)));
await appendEvent({
action: "created",
taskId: id,
to: "inbox",
statusEnteredAt: metadata.statusEnteredAt,
agent: metadata.owner,
});
print(id);
}
async function commandList(board, options) {
let cards = await loadCards(board);
if (typeof options.status === "string") {
cards = cards.filter((card) => card.metadata.status === options.status);
}
if (typeof options.owner === "string") {
cards = cards.filter((card) => card.metadata.owner === options.owner);
}
if (options.blocked) cards = cards.filter((card) => card.metadata.blocked);
cards.sort(sortCards);
printCards(cards, Boolean(options.json));
}
async function commandNext(board, options) {
const cards = await loadCards(board);
const candidates = cards
.filter(
(card) =>
card.metadata.status === "ready" &&
!card.metadata.blocked &&
Array.isArray(card.metadata.dependencies) &&
dependenciesSatisfied(card, cards),
)
.sort(sortCards);
if (candidates.length === 0) {
if (options.json) print("null");
else print("No pullable ready task.");
return;
}
printCards([candidates[0]], Boolean(options.json));
}
async function commandShow(board, id, options) {
const card = findCard(await loadCards(board), id);
if (options.json) print(JSON.stringify(card.metadata, null, 2));
else print(await readFile(card.path, "utf8"));
}
async function commandMove(board, id, destination, options) {
const cards = await loadCards(board);
const card = findCard(cards, id);
const source = card.metadata.status;
const agent = actingAgent(card, options);
const reason =
typeof options.reason === "string"
? safeText(options.reason, "--reason", 1000)
: null;
const approval =
typeof options.approval === "string"
? safeText(options.approval, "--approval", 128)
: null;
await moveCard(board, cards, card, destination, {
force: Boolean(options.force),
reason,
approval,
});
await appendEvent({
action: "moved",
taskId: card.metadata.id,
from: source,
to: destination,
agent,
reason,
approval,
resolution: card.metadata.resolution,
statusEnteredAt: card.metadata.statusEnteredAt,
forced: Boolean(options.force),
});
print(`${card.metadata.id}: ${source} -> ${destination}`);
}
function parseFieldValue(field, raw) {
if (raw === "null") return null;
if (BOOLEAN_FIELDS.has(field)) {
if (raw !== "true" && raw !== "false")
throw new Error(`${field} expects true or false`);
return raw === "true";
}
if (STRUCTURED_FIELDS.has(field)) {
let value;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error(`${field} expects JSON (${error.message})`);
}
if (!Array.isArray(value)) throw new Error(`${field} expects a JSON array`);
return value;
}
return raw;
}
async function commandSet(board, id, options) {
const field = requireOption(options, "field");
const raw = requireOption(options, "value");
if (!SETTABLE_FIELDS.has(field))
throw new Error(`field is not settable: ${field}`);
const cards = await loadCards(board);
const card = findCard(cards, id);
const agent = actingAgent(card, options);
const value = parseFieldValue(field, raw);
if (field === "priority" && !PRIORITY_ORDER.includes(value)) {
throw new Error(`unsupported priority: ${value}`);
}
if (field === "type" && !board.types.includes(value)) {
throw new Error(`unsupported type: ${value}`);
}
if (field === "resolution" && value !== null && !RESOLUTIONS.has(value)) {
throw new Error(`unsupported resolution: ${value}`);
}
const metadata = { ...card.metadata, [field]: value, updatedAt: now() };
if (metadata.blocked && !metadata.blockReason) {
throw new Error("blocked tasks require blockReason; use the block command");
}
if (!metadata.blocked && metadata.blockReason !== null) {
throw new Error("unblocked tasks must not have blockReason; use the block command");
}
if (["labels", "dependencies"].includes(field) &&
!value.every((item) => typeof item === "string" && item.length > 0)) {
throw new Error(`${field} expects non-empty strings`);
}
if (field === "dependencies" &&
(value.includes(id) || value.some((dependency) => !cards.some((candidate) => candidate.metadata.id === dependency)))) {
throw new Error("dependencies must reference existing, different tasks");
}
if (field === "dueAt" && value !== null && Number.isNaN(Date.parse(value))) {
throw new Error("dueAt expects an ISO date or null");
}
if (metadata.status === "ready") {
checkReadyDefinition({ ...card, metadata }, cards);
} else if (
["in-progress", "review", "verification", "release-ready", "done"].includes(
metadata.status,
)
) {
checkDependenciesDone({ ...card, metadata }, cards);
}
await saveCard(card, metadata);
await appendEvent({ action: "updated", taskId: id, field, agent });
print(`${id}: ${field} updated`);
}
async function commandEvidence(board, id, options) {
const stage = safeOption(options, "stage", 32);
if (!EVIDENCE_STAGES.has(stage))
throw new Error(`unsupported evidence stage: ${stage}`);
const result = safeOption(options, "result", 2000);
const cards = await loadCards(board);
const card = findCard(cards, id);
if (!EVIDENCE_STAGE_STATUSES[stage].includes(card.metadata.status)) {
throw new Error(
`${stage} evidence cannot be recorded in ${card.metadata.status}`,
);
}
const agent = actingAgent(card, options);
const evidence = [
...(Array.isArray(card.metadata.evidence) ? card.metadata.evidence : []),
{ stage, result, agent, at: now() },
];
await saveCard(card, { ...card.metadata, evidence, updatedAt: now() });
await appendEvent({ action: "evidence-added", taskId: id, stage, agent });
print(`${id}: ${stage} evidence added`);
}
async function commandClaim(board, id, options) {
const agent = safeOption(options, "agent", 64);
const cards = await loadCards(board);
const card = findCard(cards, id);
if (card.metadata.blocked) throw new Error("blocked task cannot be claimed");
if (
card.metadata.owner &&
card.metadata.startedAt &&
card.metadata.owner !== agent &&
!options.force
) {
throw new Error(
`task is owned by ${card.metadata.owner}; use --force --reason for an approved takeover`,
);
}
const reason =
typeof options.reason === "string"
? safeText(options.reason, "--reason", 1000)
: null;
if (options.force && !reason)
throw new Error("forced claim requires --reason");
if (options.force && card.metadata.priority !== "P0") {
throw new Error("forced claims are limited to P0 work");
}
if (card.metadata.status !== "ready") {
throw new Error(`claim requires ready status, got ${card.metadata.status}`);
}
checkReadyDefinition(card, cards);
card.metadata = { ...card.metadata, owner: agent, updatedAt: now() };
const source = card.metadata.status;
await moveCard(board, cards, card, "in-progress", {
claim: true,
force: false,
reason,
});
await appendEvent({
action: "claimed",
taskId: id,
from: source,
to: "in-progress",
statusEnteredAt: card.metadata.statusEnteredAt,
agent,
reason,
});
print(`${id}: claimed by ${agent}`);
}
async function commandBlock(board, id, options, blocked) {
const cards = await loadCards(board);
const card = findCard(cards, id);
const reason = blocked ? safeOption(options, "reason", 1000) : null;
const agent = actingAgent(card, options);
const metadata = {
...card.metadata,
blocked,
blockReason: reason,
updatedAt: now(),
};
await saveCard(card, metadata);
await appendEvent({
action: blocked ? "blocked" : "unblocked",
taskId: id,
agent,
reason,
});
print(`${id}: ${blocked ? "blocked" : "unblocked"}`);
}
async function commandHandoff(board, id, options) {
const from = safeOption(options, "from", 64);
const to = safeOption(options, "to", 64);
const summary = safeOption(options, "summary", 1000);
const reason =
typeof options.reason === "string"
? safeText(options.reason, "--reason", 1000)
: null;
if (options.force || reason) {
throw new Error(
"handoff takeover is not supported; the current owner must hand off",
);
}
const cards = await loadCards(board);
const card = findCard(cards, id);
if (card.metadata.handoff)
throw new Error(
`task already has pending handoff: ${card.metadata.handoff}`,
);
if (!card.metadata.owner || card.metadata.owner !== from) {
throw new Error(
`handoff sender must be current owner ${card.metadata.owner ?? "(unowned)"}`,
);
}
const timestamp = now();
const file = `${id}-${timestamp.replace(/[-:TZ.]/g, "")}.md`;
const path = join(HANDOFFS_DIR, file);
const metadata = {
schemaVersion: 1,
taskId: id,
from,
to,
status: "pending",
createdAt: timestamp,
acceptedAt: null,
};
await atomicWrite(
path,
serializeDocument(metadata, handoffBody(card, summary)),
);
const taskMetadata = {
...card.metadata,
handoff: relative(ROOT, path),
updatedAt: timestamp,
};
await saveCard(card, taskMetadata);
await appendEvent({
action: "handoff-created",
taskId: id,
from,
to,
handoff: taskMetadata.handoff,
handoffCreatedAt: metadata.createdAt,
reason: null,
forced: false,
});
print(taskMetadata.handoff);
}
async function commandAccept(board, id, options) {
const agent = safeOption(options, "agent", 64);
const cards = await loadCards(board);
const card = findCard(cards, id);
if (!card.metadata.handoff) throw new Error("task has no pending handoff");
const handoffReference = card.metadata.handoff;
const handoffPath = resolve(ROOT, handoffReference);
if (!isHandoffPath(handoffPath))
throw new Error("handoff path must remain under .kanban/handoffs");
const handoff = parseDocument(
await readFile(handoffPath, "utf8"),
handoffPath,
);
checkHandoffIntegrity(card, handoff);
if (handoff.metadata.to !== agent)
throw new Error(`handoff is addressed to ${handoff.metadata.to}`);
const timestamp = now();
await atomicWrite(
handoffPath,
serializeDocument(
{ ...handoff.metadata, status: "accepted", acceptedAt: timestamp },
handoff.body,
),
);
await saveCard(card, {
...card.metadata,
owner: agent,
handoff: null,
updatedAt: timestamp,
});
await appendEvent({
action: "handoff-accepted",
taskId: id,
from: handoff.metadata.from,
to: agent,
handoff: handoffReference,
handoffAcceptedAt: timestamp,
});
print(`${id}: handoff accepted by ${agent}`);
}
async function commandValidate(board, options) {
const errors = [];
const warnings = [];
const cards = await loadCards(board).catch((error) => {
errors.push(error.message);
return [];
});
const seen = new Set();
for (const card of cards) {
const metadata = card.metadata;
for (const field of CARD_FIELDS) {
if (!Object.hasOwn(metadata, field)) {
errors.push(`${card.path}: missing ${field}`);
}
}
for (const field of [
"id",
"title",
"type",
"priority",
"status",
"createdAt",
"updatedAt",
"statusEnteredAt",
]) {
if (!metadata[field]) errors.push(`${card.path}: empty ${field}`);
}
for (const field of Object.keys(metadata)) {
if (!CARD_FIELDS.includes(field))
errors.push(`${card.path}: unknown field ${field}`);
}
if (metadata.schemaVersion !== 1)
errors.push(`${card.path}: invalid schemaVersion`);
if (!/^KB-\d{14}-[A-F0-9]{4}$/.test(metadata.id ?? "")) {
errors.push(`${card.path}: invalid task id`);
}
if (!isSafeText(metadata.title, 200)) {
errors.push(`${metadata.id ?? card.path}: invalid title`);
}
if (metadata.owner !== null && !isSafeText(metadata.owner, 64)) {
errors.push(`${metadata.id}: invalid owner`);
}
if (typeof metadata.blocked !== "boolean") {
errors.push(`${metadata.id}: blocked must be boolean`);
}
if (
metadata.blockReason !== null &&
!isSafeText(metadata.blockReason, 1000)
) {
errors.push(`${metadata.id}: invalid blockReason`);
}
if (
!Array.isArray(metadata.labels) ||
metadata.labels.some((label) => !isSafeText(label, 64))
) {
errors.push(`${metadata.id}: labels must be safe strings`);
}
if (
metadata.estimate !== null &&
!isSafeText(String(metadata.estimate), 64)
) {
errors.push(`${metadata.id}: invalid estimate`);
}
for (const field of ["spec", "branch", "issue", "pr"]) {
if (metadata[field] !== null && !isSafeText(metadata[field], 1000)) {
errors.push(`${metadata.id}: invalid ${field}`);
}
}
if (metadata.handoff !== null && !isSafeText(metadata.handoff, 500)) {
errors.push(`${metadata.id}: invalid handoff reference`);
}
if (metadata.resolution !== null && !RESOLUTIONS.has(metadata.resolution)) {
errors.push(`${metadata.id}: invalid resolution`);
}
for (const field of ["createdAt", "updatedAt", "statusEnteredAt"]) {
if (!isIsoTimestamp(metadata[field]))
errors.push(`${metadata.id}: invalid ${field}`);
}
for (const field of ["startedAt", "completedAt", "dueAt"]) {
if (metadata[field] !== null && !isIsoTimestamp(metadata[field])) {
errors.push(`${metadata.id}: invalid ${field}`);
}
}
if (
isIsoTimestamp(metadata.createdAt) &&
isIsoTimestamp(metadata.updatedAt) &&
Date.parse(metadata.updatedAt) < Date.parse(metadata.createdAt)
) {
errors.push(`${metadata.id}: updatedAt predates createdAt`);
}
if (
isIsoTimestamp(metadata.statusEnteredAt) &&
isIsoTimestamp(metadata.createdAt) &&
isIsoTimestamp(metadata.updatedAt) &&
(Date.parse(metadata.statusEnteredAt) < Date.parse(metadata.createdAt) ||
Date.parse(metadata.statusEnteredAt) > Date.parse(metadata.updatedAt))
) {
errors.push(`${metadata.id}: statusEnteredAt is outside task lifetime`);
}
if (
isIsoTimestamp(metadata.startedAt) &&
Date.parse(metadata.startedAt) < Date.parse(metadata.createdAt)
) {
errors.push(`${metadata.id}: startedAt predates createdAt`);
}
if (
isIsoTimestamp(metadata.completedAt) &&
Date.parse(metadata.completedAt) <
Date.parse(metadata.startedAt ?? metadata.createdAt)
) {
errors.push(`${metadata.id}: completedAt predates task start`);
}
if (
isIsoTimestamp(metadata.startedAt) &&
isIsoTimestamp(metadata.updatedAt) &&
Date.parse(metadata.updatedAt) < Date.parse(metadata.startedAt)
) {
errors.push(`${metadata.id}: updatedAt predates startedAt`);
}
if (
isIsoTimestamp(metadata.completedAt) &&
isIsoTimestamp(metadata.updatedAt) &&
Date.parse(metadata.updatedAt) < Date.parse(metadata.completedAt)
) {
errors.push(`${metadata.id}: updatedAt predates completedAt`);
}
if (metadata.status !== "done" && metadata.completedAt !== null) {
errors.push(`${metadata.id}: incomplete task must not have completedAt`);
}
if (
["in-progress", "review", "verification", "release-ready"].includes(
metadata.status,
) &&
!isIsoTimestamp(metadata.startedAt)
) {
errors.push(`${metadata.id}: committed work requires startedAt`);
}
if (!metadata.blocked && metadata.blockReason !== null) {
errors.push(`${metadata.id}: unblocked task must not have blockReason`);
}
if (metadata.status === "done" && metadata.blocked) {
errors.push(`${metadata.id}: Done task cannot be blocked`);
}
if (seen.has(metadata.id)) errors.push(`duplicate task id: ${metadata.id}`);
seen.add(metadata.id);
if (`${metadata.id}.md` !== card.path.split("/").at(-1)) {
errors.push(`${card.path}: filename must be ${metadata.id}.md`);
}
if (metadata.status !== card.column.id) {
errors.push(
`${metadata.id}: status ${metadata.status} does not match ${card.column.id}`,
);
}
if (!board.types.includes(metadata.type))
errors.push(`${metadata.id}: invalid type ${metadata.type}`);
if (!PRIORITY_ORDER.includes(metadata.priority))
errors.push(`${metadata.id}: invalid priority ${metadata.priority}`);
if (!Array.isArray(metadata.dependencies)) {
errors.push(`${metadata.id}: dependencies must be an array`);
} else if (
metadata.dependencies.some(
(dependency) =>
!/^KB-\d{14}-[A-F0-9]{4}$/.test(dependency) ||
dependency === metadata.id,
)
) {
errors.push(`${metadata.id}: dependencies contain an invalid or self id`);
}
if (!Array.isArray(metadata.evidence))
errors.push(`${metadata.id}: evidence must be an array`);
else {
for (const entry of metadata.evidence) {
if (
typeof entry !== "object" ||
entry === null ||
!EVIDENCE_STAGES.has(entry.stage) ||
!isSafeText(entry.result, 2000) ||
!isSafeText(entry.agent, 64) ||
!isIsoTimestamp(entry.at)
) {
errors.push(`${metadata.id}: invalid evidence entry`);
}
}
}
if (metadata.blocked && !metadata.blockReason)
errors.push(`${metadata.id}: blocked without blockReason`);
if (
["in-progress", "review", "verification", "release-ready"].includes(
metadata.status,
) &&
!metadata.owner
) {
errors.push(`${metadata.id}: ${metadata.status} requires owner`);
}
if (
metadata.status === "done" &&
(!metadata.completedAt || !metadata.resolution)
) {
errors.push(`${metadata.id}: done requires completedAt and resolution`);
}
const requiredEvidence = requiredEvidenceStage(metadata);
if (requiredEvidence && !hasEvidence(metadata, requiredEvidence)) {
errors.push(
`${metadata.id}: ${metadata.status} requires ${requiredEvidence} evidence`,
);
}
if (metadata.status === "ready") {
try {
checkReadyDefinition(card, cards);
} catch (error) {
errors.push(`${metadata.id}: ${error.message}`);
}
}
if (metadata.handoff) {
const handoffPath = resolve(ROOT, metadata.handoff);
if (!isHandoffPath(handoffPath)) {
errors.push(
`${metadata.id}: handoff must remain under .kanban/handoffs`,
);
} else {
try {
const handoff = parseDocument(
await readFile(handoffPath, "utf8"),
handoffPath,
);
checkHandoffIntegrity(card, handoff);
} catch (error) {
errors.push(`${metadata.id}: invalid handoff (${error.message})`);
}
}
}
if (
card.body.includes("Describe the user or project outcome") &&
board.columns.findIndex((column) => column.id === metadata.status) >= 3
) {
warnings.push(`${metadata.id}: objective still contains template text`);
}
}
for (const card of cards) {
for (const dependency of card.metadata.dependencies ?? []) {
if (!seen.has(dependency))
errors.push(`${card.metadata.id}: unknown dependency ${dependency}`);
}
if (
[
"in-progress",
"review",
"verification",
"release-ready",
"done",
].includes(card.metadata.status)
) {
try {
checkDependenciesDone(card, cards);
} catch (error) {
errors.push(`${card.metadata.id}: ${error.message}`);
}
}
}
const handoffs = [];
try {
const entries = await readdir(HANDOFFS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (
!entry.isFile() ||
!entry.name.endsWith(".md") ||
entry.name === "README.md"
)
continue;
const path = join(HANDOFFS_DIR, entry.name);
const reference = relative(ROOT, path);
try {
const handoff = parseDocument(await readFile(path, "utf8"), path);
const task = cards.find(
(card) => card.metadata.id === handoff.metadata.taskId,
);
if (!task) throw new Error(`unknown taskId ${handoff.metadata.taskId}`);
if (handoff.metadata.status === "pending") {
if (task.metadata.handoff !== reference) {
throw new Error("pending handoff is not referenced by its task");
}
checkHandoffIntegrity(task, handoff);
} else if (handoff.metadata.status === "accepted") {
checkHandoffMetadata(handoff);
checkHandoffDefinition(handoff);
} else {
throw new Error(`invalid handoff status ${handoff.metadata.status}`);
}
handoffs.push({ reference, metadata: handoff.metadata });
} catch (error) {
errors.push(`${reference}: ${error.message}`);
}
}
} catch (error) {
errors.push(`cannot validate handoffs (${error.message})`);
}
const events = [];
try {
const eventText = await readFile(EVENTS_PATH, "utf8");
let previousEventAt = null;
for (const [index, line] of eventText.split("\n").entries()) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (
event.schemaVersion !== 1 ||
!isSafeText(event.action, 64) ||
!isSafeText(event.taskId, 64) ||
!isIsoTimestamp(event.at)
) {
throw new Error("invalid event fields");
}
if (!seen.has(event.taskId))
throw new Error(`unknown task ${event.taskId}`);
if (
previousEventAt &&
Date.parse(event.at) < Date.parse(previousEventAt)
) {
throw new Error("event timestamp is out of order");
}
previousEventAt = event.at;
events.push(event);
} catch (error) {
errors.push(`events.jsonl:${index + 1}: ${error.message}`);
}
}
} catch (error) {
errors.push(`cannot validate events.jsonl (${error.message})`);
}
for (const card of cards) {
const taskEvents = events.filter(
(event) => event.taskId === card.metadata.id,
);
if (!taskEvents.some((event) => event.action === "created")) {
errors.push(`${card.metadata.id}: missing created event`);
}
const statusEvents = taskEvents.filter(
(event) =>
["created", "moved", "claimed"].includes(event.action) &&
typeof event.to === "string",
);
let reconstructedStatus = null;
for (const event of statusEvents) {
if (
!isIsoTimestamp(event.statusEnteredAt) ||
Date.parse(event.statusEnteredAt) > Date.parse(event.at)
) {
errors.push(`${card.metadata.id}: invalid statusEnteredAt event`);
}
if (event.action === "created") {
if (reconstructedStatus !== null || event.to !== "inbox") {
errors.push(`${card.metadata.id}: invalid created status event`);
}
reconstructedStatus = event.to;
continue;
}
if (event.from !== reconstructedStatus) {
errors.push(`${card.metadata.id}: broken event transition chain`);
}
if (event.action === "claimed") {
if (event.from !== "ready" || event.to !== "in-progress") {
errors.push(`${card.metadata.id}: invalid claimed event`);
}
} else {
const normal = EXPECTED_TRANSITIONS[event.from]?.includes(event.to);
if (event.forced) {
const commitmentIndex = EXPECTED_COLUMNS.indexOf("in-progress");
if (
!isSafeText(event.reason, 1000) ||
EXPECTED_COLUMNS.indexOf(event.from) >= commitmentIndex ||
EXPECTED_COLUMNS.indexOf(event.to) >= commitmentIndex
) {
errors.push(`${card.metadata.id}: invalid forced transition event`);
}
} else if (!normal) {
errors.push(
`${card.metadata.id}: illegal transition event ${event.from} -> ${event.to}`,
);
}
if (event.to === "done") {
if (event.resolution === "delivered") {
if (
event.from !== "release-ready" ||
!isSafeText(event.approval, 128)
) {
errors.push(
`${card.metadata.id}: delivered event lacks Release Ready approval`,
);
}
} else if (!NON_DELIVERY_RESOLUTIONS.has(event.resolution)) {
errors.push(
`${card.metadata.id}: invalid non-delivery resolution event`,
);
}
}
}
reconstructedStatus = event.to;
}
const lastStatus = reconstructedStatus;
if (lastStatus !== card.metadata.status) {
errors.push(
`${card.metadata.id}: event status ${lastStatus ?? "(missing)"} does not match ${card.metadata.status}`,
);
}
if (
statusEvents.at(-1)?.statusEnteredAt !== card.metadata.statusEnteredAt
) {
errors.push(
`${card.metadata.id}: statusEnteredAt does not match latest status event`,
);
}
const requiredStage = requiredEvidenceStage(card.metadata);
if (requiredStage) {
const stageStatuses = EVIDENCE_STAGE_STATUSES[requiredStage];
const enteredStageAt = statusEvents
.filter((event) => stageStatuses.includes(event.to))
.at(-1)?.at;
const freshEvidence = card.metadata.evidence?.some(
(entry) =>
entry?.stage === requiredStage &&
enteredStageAt &&
Date.parse(entry.at) >= Date.parse(enteredStageAt),
);
if (!freshEvidence) {
errors.push(
`${card.metadata.id}: ${requiredStage} evidence is missing or stale`,
);
}
}
}
for (const handoff of handoffs) {
for (const action of [
"handoff-created",
...(handoff.metadata.status === "accepted" ? ["handoff-accepted"] : []),
]) {
if (
!events.some(
(event) =>
event.action === action &&
event.taskId === handoff.metadata.taskId &&
event.handoff === handoff.reference &&
event.from === handoff.metadata.from &&
event.to === handoff.metadata.to &&
(action === "handoff-created"
? event.handoffCreatedAt === handoff.metadata.createdAt
: event.handoffAcceptedAt === handoff.metadata.acceptedAt),
)
) {
errors.push(`${handoff.reference}: missing ${action} event`);
}
}
}
for (const column of board.columns) {
if (column.wipLimit === null) continue;
const count = cards.filter(
(card) => card.metadata.status === column.id,
).length;
if (count > column.wipLimit)
errors.push(`${column.title}: WIP ${count} exceeds ${column.wipLimit}`);
}
const result = {
ok: errors.length === 0,
taskCount: cards.length,
errors,
warnings,
};
if (options.json) print(JSON.stringify(result, null, 2));
else {
for (const error of errors) warn(`ERROR: ${error}`);
for (const warning of warnings) warn(`WARN: ${warning}`);
print(
`${result.ok ? "OK" : "FAILED"}: ${cards.length} tasks, ${errors.length} errors, ${warnings.length} warnings`,
);
}
if (!result.ok) process.exitCode = 1;
}
function percentile(values, fraction) {
if (values.length === 0) return null;
const sorted = [...values].sort((left, right) => left - right);
return sorted[
Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
];
}
async function commandMetrics(board, options) {
const cards = await loadCards(board);
const current = Date.now();
const days = (milliseconds) => Number((milliseconds / 86_400_000).toFixed(2));
const finished = cards.filter(
(card) => card.metadata.completedAt && card.metadata.startedAt,
);
const cycleTimes = finished.map((card) =>
days(
new Date(card.metadata.completedAt) - new Date(card.metadata.startedAt),
),
);
const activeAges = cards
.filter((card) => card.metadata.startedAt && !card.metadata.completedAt)
.map((card) => ({
id: card.metadata.id,
days: days(current - new Date(card.metadata.startedAt)),
}));
const throughput = (windowDays) =>
cards.filter(
(card) =>
card.metadata.completedAt &&
current - new Date(card.metadata.completedAt) <=
windowDays * 86_400_000,
).length;
const result = {
generatedAt: now(),
wip: Object.fromEntries(
board.columns.map((column) => [
column.id,
cards.filter((card) => card.metadata.status === column.id).length,
]),
),
throughput7Days: throughput(7),
throughput30Days: throughput(30),
cycleTimeDays: {
count: cycleTimes.length,
median: percentile(cycleTimes, 0.5),
p85: percentile(cycleTimes, 0.85),
},
oldestActive: activeAges
.sort((left, right) => right.days - left.days)
.slice(0, 5),
serviceLevelExpectation: board.serviceLevelExpectation,
};
print(JSON.stringify(result, null, 2));
}
function usage() {
print(`Usage: node .kanban/bin/kanban.mjs <command> [arguments]
Commands:
new --title TEXT [--type TYPE] [--priority P0..P3] [--owner AGENT]
list [--status STATUS] [--owner AGENT] [--blocked] [--json]
next [--json]
show ID [--json]
set ID --field FIELD --value VALUE --agent AGENT
evidence ID --stage STAGE --result TEXT --agent AGENT
move ID STATUS [--agent AGENT] [--reason TEXT] [--approval HUMAN] [--force]
claim ID --agent AGENT [--force --reason TEXT]
block ID --reason TEXT --agent AGENT
unblock ID --agent AGENT
handoff ID --from AGENT --to AGENT --summary TEXT
accept ID --agent AGENT
validate [--json]
metrics
Structured set values (labels, dependencies) must be JSON arrays.
Evidence stages are status-bound: implementation, review, verification, release, resolution.`);
}
async function main() {
const { positional, options } = parseArguments(process.argv.slice(2));
const [command = "help", first, second] = positional;
if (["help", "--help", "-h"].includes(command)) {
usage();
return;
}
const board = await loadBoard();
await withWriteLock(async () => {
if (command === "list") return commandList(board, options);
if (command === "next") return commandNext(board, options);
if (command === "show") return commandShow(board, first ?? "", options);
if (command === "validate") return commandValidate(board, options);
if (command === "metrics") return commandMetrics(board, options);
if (command === "new") return commandNew(board, options);
if (command === "set") return commandSet(board, first ?? "", options);
if (command === "evidence")
return commandEvidence(board, first ?? "", options);
if (command === "move")
return commandMove(board, first ?? "", second ?? "", options);
if (command === "claim") return commandClaim(board, first ?? "", options);
if (command === "block")
return commandBlock(board, first ?? "", options, true);
if (command === "unblock")
return commandBlock(board, first ?? "", options, false);
if (command === "handoff")
return commandHandoff(board, first ?? "", options);
if (command === "accept") return commandAccept(board, first ?? "", options);
throw new Error(`unknown command: ${command}`);
});
}
main().catch((error) => {
warn(`kanban: ${error.message}`);
process.exitCode = 1;
});