import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, readdirSync, } from "node:fs"; import { join, dirname, posix } from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { DocgraphState, ChecklistItem, DocMetadata, Ticket, TicketStatus } from "./types.js"; // ── Constants ────────────────────────────────────────────────────────── const SCHEMA_VERSION = 1; const TICKETS_DIR = "docs/tickets"; /** Ordered status columns for the Kanban board */ const KANBAN_ORDER: TicketStatus[] = [ "backlog", "ready", "in-progress", "review", "done", "blocked", ]; // ── File helpers ─────────────────────────────────────────────────────── export function ensureDir(dir: string, cwd: string): void { const full = join(cwd, dir); if (!existsSync(full)) { mkdirSync(full, { recursive: true }); } } export function readFileSafe(path: string, cwd: string): string | null { const full = join(cwd, path); try { return readFileSync(full, "utf-8"); } catch { return null; } } export function writeFileSafe( path: string, content: string, cwd: string, ): boolean { const full = join(cwd, path); try { ensureDir(dirname(path), cwd); writeFileSync(full, content, "utf-8"); return true; } catch { return false; } } // ── State management (via tool-result details) ───────────────────────── export function getState(ctx: ExtensionContext): DocgraphState { const defaultState: DocgraphState = { initialized: false, schemaVersion: SCHEMA_VERSION, }; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult" || !msg.toolName?.startsWith("docgraph_")) continue; const details = msg.details as { state?: DocgraphState } | undefined; if (details?.state) return details.state; } return defaultState; } /** Check if the docgraph documentation has been scaffolded */ export function isInitialized(ctx: ExtensionContext): boolean { const state = getState(ctx); // Also check the filesystem in case session was lost if (state.initialized) return true; const ag = readFileSafe("AGENTS.md", ctx.cwd); const rm = readFileSafe("README.md", ctx.cwd); // Detect docgraph scaffolding via markers actually written by docgraph_init return ( ag !== null && (ag.includes("## Documentation Index") || ag.includes("## Source of Truth")) && rm !== null ); } // ── Link path helpers ────────────────────────────────────────────────── /** * Human-facing link label for a canonical doc path: the basename without a * trailing `.md` extension. `docs/SPEC.md` → `SPEC`, `docs/tickets/` → `tickets`. */ export function docLabel(path: string): string { const withoutSlash = path.replace(/\/+$/, ""); return posix.basename(withoutSlash).replace(/\.md$/i, ""); } /** * Markdown href for `targetPath` (a canonical repo-root-relative path) as * seen from `sourcePath` (the file that contains the link). Pure path * calculation — never hardcoded. * * relativeLink("docs/ARCHITECTURE.md", "docs/SPEC.md") -> "SPEC.md" * relativeLink("README.md", "docs/SPEC.md") -> "docs/SPEC.md" * relativeLink("docs/SPEC.md", "AGENTS.md") -> "../AGENTS.md" */ export function relativeLink(sourcePath: string, targetPath: string): string { const rel = posix.relative(posix.dirname(sourcePath), targetPath); return rel === "" ? posix.basename(targetPath) : rel; } /** * Resolve a link href found inside `sourcePath` back to a canonical * repo-root-relative path. Follows browser-style URL resolution relative to * the containing file, so `SPEC.md` in `docs/ARCHITECTURE.md` and * `docs/SPEC.md` in `README.md` both canonicalize to `docs/SPEC.md`. */ export function canonicalDocPath(sourcePath: string, href: string): string { return posix.normalize(posix.join(posix.dirname(sourcePath), href)); } // ── Metadata parsing ─────────────────────────────────────────────────── const METADATA_LINE_RE = /^>\s*\*\*(Purpose|Audience|Source of Truth|Last Updated|Depends On|Referenced By):\*\*\s*(.*)$/; export function parseMetadata(raw: string, sourcePath: string): DocMetadata { const result: DocMetadata = { purpose: "", audience: "Both", sourceOfTruth: "Codebase (implementation is authoritative)", lastUpdated: "", dependsOn: [], referencedBy: [], }; const lines = raw.split("\n"); let inBlock = false; for (const line of lines) { if (line.startsWith("> **") && !inBlock) inBlock = true; if (!inBlock) continue; if (!line.startsWith(">")) break; const m = line.match(METADATA_LINE_RE); if (!m) continue; const [, key, value] = m as [string, string, string]; switch (key) { case "Purpose": result.purpose = value; break; case "Audience": result.audience = value.includes("Both") ? "Both" : value.includes("AI") ? "AI" : "Human"; break; case "Source of Truth": result.sourceOfTruth = value; break; case "Last Updated": result.lastUpdated = value; break; case "Depends On": result.dependsOn = parseLinkList(value, sourcePath); break; case "Referenced By": result.referencedBy = parseLinkList(value, sourcePath); break; } } return result; } function parseLinkList(raw: string, sourcePath: string): string[] { if (!raw || raw === "None" || raw === "Unknown") return []; return raw .split(",") .map((s) => s.trim()) .filter(Boolean) .map((s) => { const m = s.match(/^\[.*?\]\(([^)]+)\)/); if (!m) return ""; const href = m[1]!.trim(); // Keep only internal links; external URLs and anchors are dropped. if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href)) return ""; // Store links canonically (repo-root-relative) so re-rendering always // produces hrefs relative to whichever doc embeds them. return canonicalDocPath(sourcePath, href); }) .filter(Boolean); } // ── Metadata rendering ───────────────────────────────────────────────── export function renderMetadata(meta: DocMetadata, sourcePath: string): string { const refLink = (target: string) => `[${docLabel(target)}](${relativeLink(sourcePath, target)})`; const deps = meta.dependsOn.length > 0 ? meta.dependsOn.map(refLink).join(", ") : "None"; const refs = meta.referencedBy.length > 0 ? meta.referencedBy.map(refLink).join(", ") : "Unknown"; return [ `> **Purpose:** ${meta.purpose}`, `>`, `> **Audience:** ${meta.audience}`, `>`, `> **Source of Truth:** ${meta.sourceOfTruth}`, `>`, `> **Last Updated:** ${meta.lastUpdated}`, `>`, `> **Depends On:** ${deps}`, `>`, `> **Referenced By:** ${refs}`, ].join("\n"); } // ── Tickets persisted as separate markdown files ─────────────────────── /** * Convert a ticket title into a short kebab-case filename slug. * Lowercase, alphanumeric words joined by hyphens. Empty result when nothing usable. */ export function slugifyTitle(title: string): string { return title .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); } /** * Build the descriptive ticket filename: `T-###-[descriptive-name].md`. * The ticket ID is always preserved at the start; the slug comes from the title. */ export function ticketFileName(ticket: Pick): string { const slug = slugifyTitle(ticket.title); return slug ? `T-${ticket.id}-${slug}.md` : `T-${ticket.id}.md`; } /** Locate a ticket file by ticket ID, matching either the plain or descriptive filename. */ function ticketPathById(id: string, cwd: string): string | null { const rawId = id.replace(/^T-/, ""); const explicit = `${TICKETS_DIR}/T-${rawId}.md`; if (readFileSafe(explicit, cwd) !== null) return explicit; const dir = join(cwd, TICKETS_DIR); if (!existsSync(dir)) return null; const matcher = new RegExp(`^T-${rawId}(?:-.*)?\\.md$`); const found = readdirSync(dir).find((f) => matcher.test(f)); return found ? `${TICKETS_DIR}/${found}` : null; } export function readTicket(id: string, cwd: string): Ticket | null { const path = ticketPathById(id, cwd); if (!path) return null; const raw = readFileSafe(path, cwd); if (!raw) return null; return parseTicketFile(raw, path); } export function writeTicket(ticket: Ticket, cwd: string): boolean { const content = serializeTicketFile(ticket); const target = `${TICKETS_DIR}/${ticketFileName(ticket)}`; // If the ticket was renamed (a title change produced a new descriptive // filename), remove the stale file for the same ID so we never leave an // orphan duplicate (which would surface as a duplicate in docs/BACKLOG.md). const current = ticketPathById(ticket.id, cwd); if (current && current !== target) { try { unlinkSync(join(cwd, current)); } catch { /* best effort — a leftover file is handled by listTickets' dedup on id */ } } return writeFileSafe(target, content, cwd); } export function listTickets(cwd: string): Ticket[] { const dir = join(cwd, TICKETS_DIR); if (!existsSync(dir)) return []; // Deduplicate on ticket ID. A rename can leave a stale file for the same ID // behind (writeTicket's unlink is best-effort), which would otherwise // surface as a duplicate in docs/BACKLOG.md. Walk files in sorted order so // resolution is deterministic, and keep the canonical file per ID: newest // **Updated:** first, then a descriptively-named file that matches its own // title, then the plain `T-.md` form. const byId = new Map(); for (const file of readdirSync(dir).filter((f) => f.endsWith(".md")).sort()) { const path = `${TICKETS_DIR}/${file}`; const raw = readFileSafe(path, cwd); if (!raw) continue; const ticket = parseTicketFile(raw, path); if (!ticket) continue; const existing = byId.get(ticket.id); if (!existing || isCanonicalFile(ticket, file, existing)) { byId.set(ticket.id, { ticket, file }); } } return [...byId.values()].map((e) => e.ticket); } interface TicketFile { ticket: Ticket; file: string; } /** * True when the candidate (read from `candidateFile`) should replace `existing` * as the canonical file for its ticket ID. Freshness (the **Updated:** stamp) * is the primary signal — a stale duplicate left after a rename is always * older than the file that replaced it. Ties fall back to filename form: a * descriptively-named file that matches its own title (what writeTicket would * write today) beats a mismatched name, and the plain `T-.md` form beats * any descriptive name. */ function isCanonicalFile( candidate: Ticket, candidateFile: string, existing: TicketFile, ): boolean { const candidateFreshness = ticketFreshness(candidate); const existingFreshness = ticketFreshness(existing.ticket); if (candidateFreshness !== existingFreshness) { return candidateFreshness > existingFreshness; } const candidateForm = ticketFileName(candidate); const existingForm = ticketFileName(existing.ticket); if (candidateForm === candidateFile && existingForm !== existing.file) return true; const plain = `T-${candidate.id}.md`; return candidateFile === plain && existing.file !== plain; } /** Numeric freshness of a ticket's **Updated:** stamp; unparseable stamps lose. */ function ticketFreshness(t: Ticket): number { const ms = Date.parse(t.updatedAt ?? ""); return Number.isNaN(ms) ? -Infinity : ms; } // ── Backlog index (docs/BACKLOG.md) ──────────────────────────────────── /** * Render the canonical `docs/BACKLOG.md` from the given tickets. * * `docs/BACKLOG.md` is the central index of feature work. It is DERIVED from * the ticket files under `docs/tickets/` and should never be edited by hand — * it is regenerated by `docgraph_init`, `docgraph_ticket_create`, and * `docgraph_ticket_update`. */ export function renderBacklog(tickets: Ticket[]): string { const today = new Date().toISOString().slice(0, 10); const meta = renderMetadata( { purpose: "Canonical work queue for the project — central index of feature work and the tickets that track it.", audience: "Both", sourceOfTruth: "Codebase (implementation is authoritative)", lastUpdated: today, dependsOn: ["docs/ROADMAP.md"], referencedBy: ["AGENTS.md", "docs/ROADMAP.md", "docs/tickets/"], }, "docs/BACKLOG.md", ); const lines: string[] = [ "# Backlog", "", meta, "", `This document is the **central index** of feature work. Every implementation ticket lives under [\`${relativeLink( "docs/BACKLOG.md", "docs/tickets", )}/\`](${relativeLink("docs/BACKLOG.md", "docs/tickets")}/) and is referenced here. It is regenerated automatically by \`docgraph_init\`, \`docgraph_ticket_create\`, and \`docgraph_ticket_update\` — do not edit by hand.`, "", "Kanban columns (in order): " + KANBAN_ORDER.map((c) => `\`${c}\``).join(" · ") + ".", "", "## Index", "", "| Ticket | Status | Feature |", "|--------|--------|---------|", ]; if (tickets.length === 0) { lines.push("_(No tickets yet — ask the agent to create one.)_"); } else { for (const t of tickets) { const f = ticketFileName(t); lines.push( `| [${f}](${relativeLink("docs/BACKLOG.md", `docs/tickets/${f}`)}) | ${t.status} | ${t.title} |`, ); } } lines.push("", "---", ""); for (const col of KANBAN_ORDER) { lines.push( `## ${col .split("-") .map((w) => (w[0] ?? "").toUpperCase() + w.slice(1)) .join(" ")}`, "", ); const items = tickets.filter((t) => t.status === col); if (items.length === 0) { lines.push("_(No tickets in this column.)_"); } else { for (const t of items) { const f = ticketFileName(t); lines.push( `- [${f}](${relativeLink("docs/BACKLOG.md", `docs/tickets/${f}`)}) — ${t.title.replace(/\|/g, "\\|")}`, ); } } lines.push(""); } return lines.join("\n") + "\n"; } /** Regenerate `docs/BACKLOG.md` from the tickets in `docs/tickets/`. */ export function syncBacklog(cwd: string): boolean { const tickets = listTickets(cwd); return writeFileSafe("docs/BACKLOG.md", renderBacklog(tickets), cwd); } function parseTicketFile(raw: string, sourcePath: string): Ticket | null { const result: Partial = { dependencies: [], acceptanceCriteria: [], definitionOfDone: [], implementationNotes: [], relatedDocs: [], relatedFiles: [], }; const lines = raw.split("\n"); const sections: Record = {}; let currentSection: string | null = null; for (const line of lines) { // Metadata fields: **Key:** Value const kvMatch = line.match(/^\*\*([^*]+):\*\*\s*(.*)$/); if (kvMatch) { const [, key, value] = kvMatch as [string, string, string]; switch (key.trim()) { case "ID": result.id = value; break; case "Title": result.title = value; break; case "Status": result.status = value as TicketStatus; break; case "Priority": result.priority = value as Ticket["priority"]; break; case "Estimate": result.estimate = value || undefined; break; case "Dependencies": result.dependencies = value && value !== "None" ? value .split(",") .map((s) => s.trim()) .filter(Boolean) : []; break; case "Created": result.createdAt = value; break; case "Updated": result.updatedAt = value; break; } continue; } // Section headers start a new ## section const sectionMatch = line.match(/^##\s*(.+)$/); if (sectionMatch) { currentSection = sectionMatch[1]!.trim(); sections[currentSection] = []; continue; } // Collect body lines belonging to the current section if (currentSection && line.trim() !== "") { sections[currentSection]!.push(line); } } if (!result.id) return null; result.context = (sections["Context"] ?? []).join("\n").trim(); result.acceptanceCriteria = parseChecklist(sections["Acceptance Criteria"]); result.definitionOfDone = parseChecklist(sections["Definition of Done"]); result.implementationNotes = parsePlainList(sections["Implementation Notes"]); result.relatedDocs = parseLinks(sections["Related Documentation"], sourcePath); result.relatedFiles = parseCodeList(sections["Related Files"]); return result as Ticket; } /** * Parse checklist section lines into items, preserving the checkbox state and * any inline HTML verification comment. * * Comment-only lines — a checkbox whose text is just an HTML comment (the * legacy `- [ ] ` form), or a standalone indented * `` line written below a criterion — are folded into the previous * item so verification notes stay inline instead of becoming phantom checklist * items after a round-trip. */ function parseChecklist(lines: string[] = []): ChecklistItem[] { const items: ChecklistItem[] = []; for (const raw of lines) { const line = raw.trim(); // Standalone HTML comment (e.g. written below a criterion): attach inline. const standalone = line.match(/^\s*$/); if (standalone) { attachComment(items, standalone[1]!.trim()); continue; } const m = line.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/); if (!m) continue; const checked = m[1]!.toLowerCase() === "x"; let rest = m[2]!.trim(); let comment = ""; // Trailing inline comment: `- [x] text ` const inline = rest.match(/^(.*?)\s*\s*$/); if (inline) { rest = inline[1]!.trim(); comment = inline[2]!.trim(); } // Legacy comment-only checkbox line: fold into the previous item instead // of creating a phantom checkbox whose text is an HTML comment. if (rest === "" && comment !== "") { attachComment(items, comment); continue; } items.push({ text: rest, checked, comment }); } return items; } /** Fold a verification comment into the previous checklist item, if any. */ function attachComment(items: ChecklistItem[], comment: string): void { if (!comment) return; const last = items[items.length - 1]; if (!last) return; last.comment = last.comment ? `${last.comment} ${comment}` : comment; } /** * Parse lines like `- [label](target)`; returns canonical repo-root-relative * paths resolved relative to the ticket file. External URLs, anchors, and * absolute paths are dropped; hrefs that would escape the repo root are * clamped back inside it (best-effort repair of already-mangled links). */ function parseLinks(lines: string[] = [], sourcePath: string): string[] { return lines .map((l) => { const m = l.match(/\[([^\]]+)\]\(([^)]+)\)/); return m ? m[2]!.trim() : ""; }) .filter(Boolean) .map((href) => { // Keep only internal links; external URLs and anchors are dropped. if (href.startsWith("#") || href.startsWith("/")) return ""; if (/^(?:[a-z][a-z0-9+.-]*:)/i.test(href)) return ""; const canonical = canonicalDocPath(sourcePath, href); // Never let a (possibly legacy-mangled) href escape the repo root. return clampToRepoRoot(canonical); }) .filter(Boolean); } /** Strip leading `..` segments so a path stays inside the repo root. */ function clampToRepoRoot(path: string): string { let out = path; while (out.startsWith("../")) out = out.slice(3); return out.startsWith("..") ? posix.basename(path) : out; } /** * Render checklist items as Markdown checkboxes, preserving the checked state * and keeping any verification comment inline on the same line: * `- [x] text `. */ function renderChecklist(items: ChecklistItem[]): string { return items .map((i) => { const marker = i.checked ? "[x]" : "[ ]"; const comment = i.comment ? ` ` : ""; return `- ${marker} ${i.text}${comment}`; }) .join("\n"); } /** Parse lines like `` - `file` ``; strips the bullet and surrounding backticks. */ function parseCodeList(lines: string[] = []): string[] { return lines .map((l) => l.replace(/^[-*]\s*/, "").replace(/^`(.*)`$/, "$1").trim()) .filter(Boolean); } /** Parse plain (possibly bulleted) text lines, e.g. implementation notes. */ function parsePlainList(lines: string[] = []): string[] { return lines.map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean); } export function serializeTicketFile(ticket: Ticket): string { return [ `# T-${ticket.id}`, "", `**ID:** ${ticket.id}`, `**Title:** ${ticket.title}`, `**Status:** ${ticket.status}`, `**Priority:** ${ticket.priority}`, `**Estimate:** ${ticket.estimate || ""}`, `**Dependencies:** ${ticket.dependencies.join(", ") || "None"}`, `**Created:** ${ticket.createdAt}`, `**Updated:** ${ticket.updatedAt}`, "", "## Context", "", ticket.context || "", "", "## Acceptance Criteria", "", ticket.acceptanceCriteria.length > 0 ? renderChecklist(ticket.acceptanceCriteria) : "None yet", "", "## Definition of Done", "", ticket.definitionOfDone.length > 0 ? renderChecklist(ticket.definitionOfDone) : "None yet", "", "## Implementation Notes", "", ticket.implementationNotes.length > 0 ? ticket.implementationNotes.map((n: string) => `- ${n}`).join("\n") : "None yet", "", "## Related Documentation", "", ticket.relatedDocs.length > 0 ? ticket.relatedDocs .map( (d: string) => `- [${docLabel(d)}](${relativeLink( `docs/tickets/${ticketFileName(ticket)}`, d, )})`, ) .join("\n") : "None yet", "", "## Related Files", "", ticket.relatedFiles.length > 0 ? ticket.relatedFiles.map((f: string) => `- \`${f}\``).join("\n") : "None yet", "", ].join("\n"); } // ── Cross-reference validation ───────────────────────────────────────── /** * Validate that all Markdown links in a document body point to existing files. * Internal hrefs are resolved relative to the directory of `sourcePath`, the * file that contains them, not relative to the repo root. Returns a list of * broken links. */ export function validateLinks( body: string, sourcePath: string, cwd: string, ): { valid: boolean; broken: string[] } { const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g; const broken: string[] = []; const sourceDir = sourcePath.split("/").slice(0, -1).filter(Boolean); let match: RegExpExecArray | null; while ((match = linkRe.exec(body)) !== null) { const target = match[2]!.trim(); // Skip external links, protocol-relative URLs, anchors, absolute paths if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)) continue; // Resolve relative to the source file's directory (URL semantics) const resolved = join(cwd, ...sourceDir, target); if (!existsSync(resolved)) { broken.push(target); } } return { valid: broken.length === 0, broken }; } /** Generate a back-of-envelope ticket ID from existing tickets */ export function nextTicketId(cwd: string): string { const existing = listTickets(cwd); const highest = existing.reduce((max, t) => { const num = Number.parseInt(t.id, 10); return Number.isNaN(num) ? max : Math.max(max, num); }, 0); return String(highest + 1).padStart(3, "0"); } export { KANBAN_ORDER }; /** Extract text content from a tool result content item (TextContent | ImageContent) */ export function contentText(item: { type: string; text?: string } | undefined): string { return item?.type === "text" && typeof item.text === "string" ? item.text : ""; }