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