repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/the-system/board-state.ts
Rawimport { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { BOARD_NOTE_LIMIT } from "./board-constants.ts";
import type { BundleDocumentRecord, DocumentMetadata } from "./documents.ts";
import { resolveGovernedPath, SystemDocumentError } from "./project-root.ts";
import { parseDocumentId } from "./system-config.ts";
export const BOARD_SCHEMA_VERSION = 0;
export const BOARD_PATH = ".system/board.json";
export { BOARD_NOTE_LIMIT } from "./board-constants.ts";
export type BoardItemState = "todo" | "done" | "open";
export type BoardItemSource = "manual" | "assess" | "agent";
export type BoardEntry = Readonly<{
state: BoardItemState;
updatedAt: string;
source: BoardItemSource;
note?: string;
}>;
export type BoardProposal = Readonly<{
id: string;
state: BoardItemState;
rationale: string;
}>;
export type BoardState = Readonly<{
schemaVersion: number;
items: Readonly<Record<string, BoardEntry>>;
}>;
const objectValue = (
value: unknown,
label: string,
): Record<string, unknown> => {
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new SystemDocumentError(
"board.shape",
`${label} must be a JSON object`,
);
return value as Record<string, unknown>;
};
const exactKeys = (
value: Record<string, unknown>,
required: readonly string[],
label: string,
optional: readonly string[] = [],
) => {
const actual = Object.keys(value).sort();
if (
required.some((key) => !(key in value)) ||
actual.some((key) => !required.includes(key) && !optional.includes(key))
)
throw new SystemDocumentError(
"board.keys",
`${label} expected keys ${[...required].sort().join(", ")}; received ${actual.join(", ")}`,
);
};
const validState = (type: DocumentMetadata["type"], state: string): boolean => {
switch (type) {
case "spec":
case "plan":
return state === "todo" || state === "done";
case "issue":
return state === "open" || state === "done";
default:
return false;
}
};
export function parseBoardState(source: string, prefix: string): BoardState {
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new SystemDocumentError("board.invalid-json", "Invalid board JSON");
}
const value = objectValue(parsed, "Board");
if (value.schemaVersion !== BOARD_SCHEMA_VERSION)
throw new SystemDocumentError(
"board.version",
`Board schema version ${String(value.schemaVersion)} does not match ${BOARD_SCHEMA_VERSION}; run /system init`,
);
exactKeys(value, ["items", "schemaVersion"], "Board");
const rawItems = objectValue(value.items, "Board items");
const items: Record<string, BoardEntry> = {};
for (const [id, rawEntry] of Object.entries(rawItems)) {
const parsedId = parseDocumentId(id, prefix);
if (!["spec", "plan", "issue"].includes(parsedId.type))
throw new SystemDocumentError(
"board.item-type",
`Board item must reference a spec, plan, or issue: ${id}`,
);
const entry = objectValue(rawEntry, `Board item ${id}`);
exactKeys(entry, ["source", "state", "updatedAt"], `Board item ${id}`, [
"note",
]);
if (typeof entry.state !== "string")
throw new SystemDocumentError(
"board.state",
`Board item state must be a string: ${id}`,
);
if (!validState(parsedId.type, entry.state))
throw new SystemDocumentError(
"board.state",
`Invalid ${parsedId.type} board state ${entry.state}: ${id}`,
);
if (
entry.source !== "manual" &&
entry.source !== "assess" &&
entry.source !== "agent"
)
throw new SystemDocumentError(
"board.source",
`Invalid board source for ${id}`,
);
if (
entry.note !== undefined &&
(typeof entry.note !== "string" ||
entry.note.trim().length === 0 ||
entry.note.length > BOARD_NOTE_LIMIT)
)
throw new SystemDocumentError(
"board.note",
`Board note must be 1-${BOARD_NOTE_LIMIT} characters: ${id}`,
);
if (
typeof entry.updatedAt !== "string" ||
Number.isNaN(Date.parse(entry.updatedAt)) ||
new Date(entry.updatedAt).toISOString() !== entry.updatedAt
)
throw new SystemDocumentError(
"board.updated-at",
`Board updatedAt must be a canonical ISO timestamp: ${id}`,
);
items[id] = {
state: entry.state as BoardItemState,
updatedAt: entry.updatedAt,
source: entry.source,
...(entry.note === undefined ? {} : { note: entry.note as string }),
};
}
return { schemaVersion: BOARD_SCHEMA_VERSION, items };
}
export function boardStateForInitialization(
source: string,
prefix: string,
): { state: BoardState; sourceSchemaVersion?: number } {
try {
return { state: parseBoardState(source, prefix) };
} catch (error) {
if (
!(error instanceof SystemDocumentError) ||
error.code !== "board.version"
)
throw error;
const parsed = objectValue(JSON.parse(source), "Board");
if (
typeof parsed.schemaVersion !== "number" ||
!Number.isInteger(parsed.schemaVersion)
)
throw error;
const state = parseBoardState(
JSON.stringify({
schemaVersion: BOARD_SCHEMA_VERSION,
items: parsed.items,
}),
prefix,
);
return { state, sourceSchemaVersion: parsed.schemaVersion };
}
}
export const emptyBoardState = (): BoardState => ({
schemaVersion: BOARD_SCHEMA_VERSION,
items: {},
});
export function boardStateSource(state: BoardState): string {
return `${JSON.stringify(
{
schemaVersion: BOARD_SCHEMA_VERSION,
items: Object.fromEntries(
Object.entries(state.items).sort(([left], [right]) =>
left.localeCompare(right),
),
),
},
null,
"\t",
)}\n`;
}
export function readBoardState(root: string, prefix: string): BoardState {
return parseBoardState(
readFileSync(resolveGovernedPath(root, BOARD_PATH), "utf8"),
prefix,
);
}
export function defaultBoardState(metadata: DocumentMetadata): BoardItemState {
return metadata.type === "issue" ? "open" : "todo";
}
export function boardStateFor(
board: BoardState,
metadata: DocumentMetadata,
): BoardItemState {
return board.items[metadata.id]?.state ?? defaultBoardState(metadata);
}
export function updateBoardState(
board: BoardState,
documents: readonly BundleDocumentRecord[],
id: string,
state: BoardItemState,
source: BoardItemSource,
updatedAt = new Date().toISOString(),
note?: string,
): BoardState {
const document = documents.find((candidate) => candidate.metadata.id === id);
if (!document)
throw new SystemDocumentError(
"board.missing-item",
`No System board item: ${id}`,
);
if (!validState(document.metadata.type, state))
throw new SystemDocumentError(
"board.state",
`Invalid ${document.metadata.type} board state ${state}: ${id}`,
);
const trimmedNote = note?.trim();
if (trimmedNote !== undefined && trimmedNote.length > BOARD_NOTE_LIMIT)
throw new SystemDocumentError(
"board.note",
`Board note must be 1-${BOARD_NOTE_LIMIT} characters: ${id}`,
);
const items: Record<string, BoardEntry> = { ...board.items };
items[id] = {
state,
source,
updatedAt,
...(trimmedNote ? { note: trimmedNote } : {}),
};
if (document.metadata.type === "spec" && state === "done") {
const unfinished = documents.filter(
(candidate) =>
candidate.metadata.type === "plan" &&
candidate.metadata.spec === id &&
(items[candidate.metadata.id]?.state ?? "todo") !== "done",
);
if (unfinished.length)
throw new SystemDocumentError(
"board.spec-plans",
`Spec ${id} cannot be done while plans remain todo: ${unfinished.map((item) => item.metadata.id).join(", ")}`,
);
}
if (document.metadata.type === "plan" && state === "todo") {
const specId = document.metadata.spec;
const spec = documents.find(
(candidate) =>
candidate.metadata.type === "spec" && candidate.metadata.id === specId,
);
if (spec && (items[spec.metadata.id]?.state ?? "todo") === "done")
items[spec.metadata.id] = { state: "todo", source, updatedAt };
}
return { schemaVersion: BOARD_SCHEMA_VERSION, items };
}
export function writeBoardState(root: string, state: BoardState): void {
const target = resolveGovernedPath(root, BOARD_PATH);
const temporary = path.join(
path.dirname(target),
`.board-${process.pid}-${Date.now()}.json`,
);
writeFileSync(temporary, boardStateSource(state), {
encoding: "utf8",
flag: "wx",
});
try {
renameSync(temporary, target);
} finally {
rmSync(temporary, { force: true });
}
}