import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import { parse, stringify } from "yaml"; import { applyTaskOperation, createEmptyArtifact, filterTasks, formatTaskList, validateArtifact, type Task, type TaskArtifact, type TaskIdHashGenerator, type TaskOperation, type TaskOperationResult, } from "./tasks"; export const TASKS_DIR = ".pi"; export const TASKS_FILE = "telos-tasks.md"; export type ArtifactLoad = { artifact: TaskArtifact; existed: boolean; }; export function taskFilePath(cwd: string): string { return join(cwd, TASKS_DIR, TASKS_FILE); } export function parseArtifactText(text: string): TaskArtifact { const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text); if (!match) throw new Error("telos-tasks.md must start with Telos YAML frontmatter delimited by ---"); let raw: unknown; try { raw = parse(match[1]); } catch (error) { throw new Error(`Failed to parse Telos YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`); } return validateArtifact(raw); } export function serializeArtifact(artifact: TaskArtifact): string { const validated = validateArtifact(artifact); const frontmatter = stringify(validated, { lineWidth: 0 }).trimEnd(); return `---\n${frontmatter}\n---\n\n${renderMarkdownBody(validated.tasks)}`; } export function renderMarkdownBody(tasks: Task[]): string { const active = filterTasks(tasks, "active"); const archived = filterTasks(tasks, "archived"); const doneCount = tasks.filter((task) => task.status === "done").length; const lines: string[] = [ "# Tasks", "", "> Generated by Telos. The YAML frontmatter above is the source of truth; this Markdown body is regenerated after successful Telos mutations.", "", "## Summary", "", `- Active: ${active.length}`, `- Archived: ${archived.length}`, `- Done: ${doneCount}`, `- Total: ${tasks.length}`, "", "## Active", "", ]; appendTaskTable(lines, active, "No active tasks."); lines.push("", "## Archived", ""); appendTaskTable(lines, archived, "No archived tasks."); lines.push(""); return lines.join("\n"); } export async function loadTaskArtifact(filePath: string): Promise { try { await access(filePath); } catch { return { artifact: createEmptyArtifact(), existed: false }; } const text = await readFile(filePath, "utf8"); return { artifact: parseArtifactText(text), existed: true }; } export async function writeTaskArtifact(filePath: string, artifact: TaskArtifact): Promise { await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, serializeArtifact(artifact), "utf8"); } export async function mutateTaskArtifact( filePath: string, operation: TaskOperation, now: () => Date = () => new Date(), generateIdHash?: TaskIdHashGenerator, ): Promise { return withFileMutationQueue(filePath, async () => { const before = await loadTaskArtifact(filePath); const result = applyTaskOperation(before.artifact, operation, now, generateIdHash); if (result.artifact.tasks.length < before.artifact.tasks.length) { throw new Error("Task operation would remove an existing task record; refusing to write telos-tasks.md"); } if (!result.rejected && isMutatingOperation(operation)) { await writeTaskArtifact(filePath, result.artifact); } return result; }); } export function isMutatingOperation(operation: TaskOperation): boolean { return operation.action !== "list" && operation.action !== "show" && operation.action !== "delete"; } export function formatArtifactList(artifact: TaskArtifact, scope: "active" | "archived" | "all" = "active"): string { return formatTaskList(filterTasks(artifact.tasks, scope), scope); } function appendTaskTable(lines: string[], tasks: Task[], emptyText: string): void { if (tasks.length === 0) { lines.push(emptyText); return; } lines.push("| ID | Status | Priority | Depends on | Title | Updated |", "| --- | --- | --- | --- | --- | --- |"); for (const task of tasks) { const dependencies = task.dependencies.length > 0 ? task.dependencies.join(", ") : "—"; lines.push( `| ${escapeTable(task.id)} | ${escapeTable(task.status)} | ${escapeTable(task.priority)} | ${escapeTable(dependencies)} | ${escapeTable(task.title)} | ${escapeTable(task.updated)} |`, ); } } function escapeTable(value: string): string { return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " "); }