AkurAI Build
Menu

AkurAI-Build

public

Latest 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;
});