UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

282 lines 12.9 kB
import { readdir, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { TICKETS_DIR } from '../tickets.js'; import { ticketLockHolder } from '../ticket-locks.js'; /** The meta file's name inside `tickets/`. */ const META_FILE = 'meta.json'; /** Big enough for a stamp and whatever is agreed later; small enough that a junk file cannot be read whole. */ const MAX_META_BYTES = 10_000; /** How much of a ticket is read looking for its heading and TLDR. */ const MAX_TICKET_BYTES = 4_000; /** A ticket's siblings, which are not tickets of their own. */ const SIBLING = /\.(plan|lock)\.md$/; /** * Whether the project has any ticket at all (#958). * * A `readdir` rather than a {@link readTickets} parse: the Onboarding checklist only needs * presence, and it asks for every project on each dashboard poll, so reading and describing * every ticket to answer a yes/no would be paid over and over. */ export async function hasTickets(cwd) { const names = await readdir(join(cwd, TICKETS_DIR)).catch(() => []); return names.some(name => name.endsWith('.md') && !SIBLING.test(name)); } /** * The last-import stamp, or `{}` when there is none to read (#1208). * * Every failure lands on the same answer — no file, unreadable, not JSON, a `lastImportedAt` that * is not a usable date — because the file is written by an agent and read into the UI. "We do not * know when this last synced" is a true and harmless thing to say; throwing at the view over a * malformed optional file is not. */ export async function readTicketsMeta(cwd) { const raw = await readFile(join(cwd, TICKETS_DIR, META_FILE), 'utf8').catch(() => undefined); if (raw === undefined) return {}; let parsed; try { parsed = JSON.parse(raw.slice(0, MAX_META_BYTES)); } catch { return {}; } if (typeof parsed !== 'object' || parsed === null) return {}; const stamp = parsed['lastImportedAt']; // Parsed rather than merely non-empty: the value is rendered as a date, and a string the // browser cannot parse would show as "Invalid Date" in the one place claiming to be factual. if (typeof stamp !== 'string' || Number.isNaN(Date.parse(stamp))) return {}; return { lastImportedAt: stamp }; } /** * A filename made readable, for a ticket with no heading. The format is * `<DATE>_<SLUG>.md`, but the tickets imported from GitHub are `<number>-<escaped title>.md`, * so decoding and de-underscoring gets both most of the way there. */ function titleFromFile(file) { const withoutExt = file.replace(/\.md$/, ''); try { return decodeURIComponent(withoutExt).replace(/_/g, ' '); } catch { // A stray `%` is not an escape; the raw name still reads better than throwing. return withoutExt.replace(/_/g, ' '); } } /** * Read the head of a ticket: the `key: value` block above the title (`priority:`, `topics:`, and * whatever else is agreed later — all optional), the `# ` heading, and the `## TLDR`. * * Deliberately tolerant. The tickets already in a repo predate the format (they are GitHub * imports: a heading, prose, and a trailing `Source:` line), so anything missing falls back * rather than dropping the ticket from the list. */ function describe(md) { const lines = md.split('\n'); const heading = lines.find(line => line.startsWith('# '))?.slice(2).trim(); // The key block is above the title, so stop there rather than reading keys out of the body. const headingAt = lines.findIndex(line => line.startsWith('# ')); const preamble = headingAt === -1 ? [] : lines.slice(0, headingAt); const priority = preamble .find(line => line.toLowerCase().startsWith('priority:')) ?.slice('priority:'.length) .trim() .toLowerCase(); // `topics: [dx, ui]` — the brackets are cosmetic (the format doc shows them, but nothing else // in this reader requires them), so they are stripped rather than required. const topicsLine = preamble.find(line => line.toLowerCase().startsWith('topics:'))?.slice('topics:'.length).trim(); const topics = topicsLine ?.replace(/^\[/, '') .replace(/\]$/, '') .split(',') .map(t => t.trim()) .filter(Boolean); // `GitHub: [#42](https://github.com/org/repo/issues/42)` — a bare Markdown link, so pulling the // label and URL back out of it is all parsing this needs. const githubLine = preamble.find(line => line.toLowerCase().startsWith('github:'))?.slice('github:'.length).trim(); const githubMatch = githubLine ? /\[([^\]]+)\]\(([^)]+)\)/.exec(githubLine) : null; const github = githubMatch ? { label: githubMatch[1], url: githubMatch[2] } : undefined; // The TLDR is the ticket in one line, which is exactly what a list row wants. const tldrAt = lines.findIndex(line => line.trim().toLowerCase() === '## tldr'); const body = tldrAt === -1 ? lines.slice(headingAt + 1) : lines.slice(tldrAt + 1); const summary = body.find(line => line.trim() !== '' && !line.startsWith('#') && !line.startsWith('Source:'))?.trim() ?? ''; return { ...(heading ? { title: heading } : {}), ...(priority ? { priority } : {}), ...(topics && topics.length > 0 ? { topics } : {}), ...(github ? { github } : {}), summary, }; } /** `<DATE>_<SLUG>.md`'s `<DATE>`, at midnight UTC. */ const FILENAME_DATE = /^(\d{4}-\d{2}-\d{2})_/; /** * The date a ticket's own filename carries (#1144/#1265), when it carries one. Every ticket the * format describes is written `<DATE>_<SLUG>.md` — imports included, since the import preset * follows the same format — so this is the one true "when" for a ticket, unlike the file's mtime, * which moves every time the file is merely edited (a GitHub update reconciling it, #1208). */ function dateFromFilename(file) { const match = FILENAME_DATE.exec(file); return match ? `${match[1]}T00:00:00.000Z` : undefined; } /** A file's mtime as ISO 8601, or the epoch when it cannot be stat'd — sorts last, not thrown. */ async function fileDate(path) { const info = await stat(path).catch(() => undefined); return (info?.mtime ?? new Date(0)).toISOString(); } /** A ticket's date (#1144/#1265): its filename's, else its mtime for the rare ticket predating * the dated-filename format. */ async function ticketDate(dir, file) { return dateFromFilename(file) ?? fileDate(join(dir, file)); } /** * A plan preamble's `0`-`10` value, or `undefined` when the key is missing or does not name one. * Out-of-range and fractional values are not clamped into something plausible, same as * `todoPriorityForTicket`: they are not a value on this scale, and inventing one hides the typo. */ function planScale(preamble, key) { const written = preamble .find(line => line.toLowerCase().startsWith(`${key}:`)) ?.slice(key.length + 1) .trim(); if (written === undefined || !/^\d+$/.test(written)) return undefined; const value = Number(written); return value >= 0 && value <= 10 ? value : undefined; } /** * What a `.plan.md`'s preamble records (`ticketing_format.md`): `Effort: 0-10` and * `Uncertainty: 0-10`, the keys above the `# [Plan]` heading. Reads only up to the heading, like * a ticket's own preamble — the body is the plan, not metadata. */ function planMeta(md) { if (md === undefined) return {}; const lines = md.slice(0, MAX_TICKET_BYTES).split('\n'); const headingAt = lines.findIndex(line => line.startsWith('# ')); const preamble = headingAt === -1 ? lines : lines.slice(0, headingAt); const effort = planScale(preamble, 'effort'); const uncertainty = planScale(preamble, 'uncertainty'); return { ...(effort === undefined ? {} : { effort }), ...(uncertainty === undefined ? {} : { uncertainty }) }; } /** * One sibling's state: absent, or present with its content for the preamble read. Existence is the * answer again since #1420 — a claim is its own `.lock.md` file, never placeholder content * inside a plan. */ async function readSibling(dir, name, siblings) { if (!siblings.has(name)) return { real: false }; const md = await readFile(join(dir, name), 'utf8').catch(() => undefined); return { real: true, ...(md === undefined ? {} : { md }) }; } /** * A ticket's `.lock.md` claim (#1420): whether it exists, and who its `CLAIMED:` line names. An * unreadable or malformed lock still locks — the file's existence is the claim; the holder is * display sugar. */ async function readLock(dir, name, siblings) { if (!siblings.has(name)) return { locked: false }; const md = await readFile(join(dir, name), 'utf8').catch(() => undefined); const lockedBy = md === undefined ? undefined : ticketLockHolder(md); return { locked: true, ...(lockedBy === undefined ? {} : { lockedBy }) }; } /** * The project's tickets, by filename, newest first (#1144). `[]` when the repo has no `tickets/` * directory at all, which is the state the view offers to import into. * * A `.plan.md` or `.lock.md` is written *about* a ticket rather than being one, so it never * becomes a row of its own: it marks its ticket instead. */ export async function readTickets(cwd) { const dir = join(cwd, TICKETS_DIR); const names = await readdir(dir).catch(() => []); const md = names.filter(name => name.endsWith('.md')).sort(); const siblings = new Set(md.filter(name => SIBLING.test(name))); const tickets = []; for (const file of md) { if (siblings.has(file)) continue; // Only the head: a ticket can be long, and nothing below it is shown. const [content, date] = await Promise.all([ readFile(join(dir, file), 'utf8').catch(() => undefined), ticketDate(dir, file), ]); if (content === undefined) continue; const stem = file.replace(/\.md$/, ''); const { title, summary, priority, topics, github } = describe(content.slice(0, MAX_TICKET_BYTES)); const [plan, lock] = await Promise.all([ readSibling(dir, `${stem}.plan.md`, siblings), readLock(dir, `${stem}.lock.md`, siblings), ]); tickets.push({ file, title: title ?? titleFromFile(file), summary, ...(priority ? { priority } : {}), ...(topics ? { topics } : {}), ...(github ? { github } : {}), date, planned: plan.real, ...(lock.locked ? { locked: true } : {}), ...(lock.lockedBy !== undefined ? { lockedBy: lock.lockedBy } : {}), ...planMeta(plan.md), }); } // Newest first: what changed most recently is what the list is for (#1144), and it is the only // ordering that means the same thing for a dated ticket and a bare GitHub-imported one alike. tickets.sort((a, b) => b.date.localeCompare(a.date)); return tickets; } /** * A bare filename inside `tickets/`: no path segments (so it cannot address another directory) * and not one of a ticket's own siblings (a `.plan.md`/`.lock.md` is written about a ticket, not * one itself, same as {@link readTickets}). Exported for the RPCs that take a ticket filename * from the browser (#1420's release). */ export function isTicketFile(file) { return /^[^/\\]+\.md$/.test(file) && !SIBLING.test(file); } /** * One ticket by filename, full text included, for its own page (#1144) rather than the list's * head-only row. Null when `file` is not a bare `.md` name, is a sibling rather than a ticket, * or does not exist. */ export async function readTicket(cwd, file) { if (!isTicketFile(file)) return null; const dir = join(cwd, TICKETS_DIR); const [content, date, names] = await Promise.all([ readFile(join(dir, file), 'utf8').catch(() => undefined), ticketDate(dir, file), readdir(dir).catch(() => []), ]); if (content === undefined) return null; const stem = file.replace(/\.md$/, ''); const { title, summary, priority, topics, github } = describe(content); const present = new Set(names); const [plan, lock] = await Promise.all([ readSibling(dir, `${stem}.plan.md`, present), readLock(dir, `${stem}.lock.md`, present), ]); return { file, title: title ?? titleFromFile(file), summary, ...(priority ? { priority } : {}), ...(topics ? { topics } : {}), ...(github ? { github } : {}), date, planned: plan.real, ...(lock.locked ? { locked: true } : {}), ...(lock.lockedBy !== undefined ? { lockedBy: lock.lockedBy } : {}), ...planMeta(plan.md), content, }; } //# sourceMappingURL=tickets.js.map