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
JavaScript
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