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.

171 lines 8.22 kB
import { join } from 'node:path'; import { readdir, readFile, writeFile } from 'node:fs/promises'; import { errorMessage } from './error-message.js'; import { nodeGitRunner } from './project.js'; import { FLAT_TODO_FILE, TICKETS_DIR, todoPriorityForTicket } from './tickets.js'; const EFFORTS = ['quick-win', 'significant']; const CONSENSUS = ['consensual', 'open-questions']; /** * Read a plan file's verdict keys. * * Only the header is scanned -- everything above the first `##` section -- because that is where * the ticket format puts its keys, and because a plan that *discusses* quick wins in its prose * must not be read as declaring itself one. */ export function parsePlanVerdict(md) { const verdict = {}; for (const line of md.split('\n')) { if (line.startsWith('## ')) break; const key = /^\s*(Effort|Consensus)\s*:\s*(.+?)\s*$/i.exec(line); if (!key) continue; const value = key[2].toLowerCase(); if (/^effort$/i.test(key[1])) { if (EFFORTS.includes(value) && verdict.effort === undefined) verdict.effort = value; } else if (CONSENSUS.includes(value) && verdict.consensus === undefined) { verdict.consensus = value; } } return verdict; } /** * Whether a plan authorises the drain to implement its ticket unattended. * * Fails closed, and demands both keys explicitly: the same polarity as `quotaHeadroom` (#879), * for the same reason. A plan that forgot to say, or said something this version does not * recognise, means a human decides -- not that an agent starts. */ export function isAutoImplementable(verdict) { return verdict.effort === 'quick-win' && verdict.consensus === 'consensual'; } /** `tickets/<slug>.plan.md` for `tickets/<slug>.md`, and the read back. */ export function planPathFor(ticket) { return `${ticket.slice(0, -'.md'.length)}.plan.md`; } /** The ticket a plan belongs to, or undefined for a file that is not one. */ export function ticketForPlan(plan) { if (!plan.endsWith('.plan.md')) return undefined; return `${plan.slice(0, -'.plan.md'.length)}.md`; } /** * Read a ticket's header. Keys sit above the `# Title`, per the ticket format. * * A ticket with no `Status:` counts as open: the key is what a *closed* ticket is marked with, * and treating an unmarked one as closed would silently drop it out of the roadmap. */ export function parseTicketHeader(md) { const title = /^#\s+(.+?)\s*$/m.exec(md)?.[1]?.trim() ?? ''; const status = /^\s*Status\s*:\s*(.+?)\s*$/im.exec(md)?.[1]?.trim().toLowerCase(); const priority = /^\s*Priority\s*:\s*(.+?)\s*$/im.exec(md)?.[1]?.trim(); return { title, priority: todoPriorityForTicket(priority), open: status !== 'closed' }; } /** The queue line for a planned ticket: the link, and nothing else. */ export function queueEntryFor(ticket, title) { return `[${title}](${ticket})`; } const PRIORITY_HEADING = /^##\s+Priority\s+(\d+)\b/; /** * Add an entry under its priority heading, creating the section when the file has none. * * Placement is the whole point rather than a nicety: `parseTodoEntries` returns entries in file * order and the drain takes the first, so an entry appended to the end of the file is the last * thing that would ever be worked -- which is the opposite of what "autonomously work on * quick-wins" asks for. Additive like `landPinnedEntry`, so it composes with whatever else is * mid-flight: it only ever inserts one line. */ export function insertQueueEntry(md, entry, priority) { const lines = md.split('\n'); const headings = lines.flatMap((line, i) => { const found = PRIORITY_HEADING.exec(line); return found ? [{ index: i, priority: Number(found[1]) }] : []; }); const line = `- ${entry}`; if (!headings.length) { const body = md.endsWith('\n') || md === '' ? md : `${md}\n`; return `${body}${line}\n`; } const own = headings.find(h => h.priority === priority); if (own) { // The end of the section: the line before the next heading, with trailing blanks left where // they are so the file's spacing survives. const next = headings.find(h => h.index > own.index); let end = next ? next.index : lines.length; while (end > own.index + 1 && lines[end - 1].trim() === '') end--; return [...lines.slice(0, end), line, ...lines.slice(end)].join('\n'); } // No section for this priority yet. Sections run high to low, so the new one goes before the // first section that is less urgent, or after the last one when it is the least urgent of all. const before = headings.find(h => h.priority < priority); const at = before ? before.index : lines.length; const section = [`## Priority ${priority}`, '', line, '']; if (!before) { while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); return [...lines, '', ...section].join('\n'); } return [...lines.slice(0, at), ...section, ...lines.slice(at)].join('\n'); } /** The commit message a promotion writes, naming the count so the history reads at a glance. */ export function plannedQueueMessage(count) { return `[The Framework] queue ${count} planned quick-win${count === 1 ? '' : 's'}`; } /** * Queue every ticket whose plan declares itself a consensual quick-win and that is not on the * queue already (#1334). * * Skips wholesale on a dirty queue file, for the same reason `promoteQueue` does: a human editing * the queue by hand outranks an unattended tidy-up, and the next tick will try again. */ export async function promotePlannedQuickWins(projectCwd, deps = {}) { const list = deps.list ?? (dir => readdir(dir)); const read = deps.read ?? (path => readFile(path, 'utf8')); const write = deps.write ?? ((path, content) => writeFile(path, content, 'utf8')); const git = deps.git ?? nodeGitRunner(); try { const files = await list(join(projectCwd, TICKETS_DIR)).catch(() => []); const plans = files.filter(file => file.endsWith('.plan.md')).sort(); if (!plans.length) return { queued: [], reason: 'no plans yet' }; const queuePath = join(projectCwd, FLAT_TODO_FILE); let queue = await read(queuePath).catch(() => ''); const dirty = (await git(['status', '--porcelain', '--', FLAT_TODO_FILE], projectCwd)).trim(); if (dirty) return { queued: [], reason: 'the checkout has uncommitted queue changes', blocked: true }; const queued = []; for (const plan of plans) { const ticket = ticketForPlan(`${TICKETS_DIR}/${plan}`); if (!ticket) continue; // Already on the queue, open or checked off: `queue.includes` on the link target is enough // because the entry is written as a link to exactly this path. if (queue.includes(`(${ticket})`)) continue; const verdict = parsePlanVerdict(await read(join(projectCwd, TICKETS_DIR, plan)).catch(() => '')); if (!isAutoImplementable(verdict)) continue; const header = parseTicketHeader(await read(join(projectCwd, ticket)).catch(() => '')); // A ticket whose file is gone or whose title is empty has nothing a queue line could say, // and a closed one is not work. if (!header.title || !header.open) continue; const entry = queueEntryFor(ticket, header.title); queue = insertQueueEntry(queue, entry, header.priority); queued.push(entry); } if (!queued.length) return { queued: [], reason: 'no plan called its ticket a consensual quick-win' }; await write(queuePath, queue); await git(['add', '--', FLAT_TODO_FILE], projectCwd); await git(['commit', '-m', plannedQueueMessage(queued.length), '--', FLAT_TODO_FILE], projectCwd); return { queued }; } catch (err) { return { queued: [], reason: errorMessage(err), blocked: true }; } } //# sourceMappingURL=planned-quick-wins.js.map