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.
341 lines • 17.8 kB
JavaScript
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { requestChoices, runAwaitRounds } from './await-gate.js';
import { FLAT_TODO_FILE, ticketFromQueueEntry } from './tickets.js';
import { dataProjectRoot, readDataFile, withDataBranch } from './data-branch.js';
import { drainsQueue } from './preset-catalog.js';
import { createTurnSignalEmitter } from './turn-gate.js';
export { FLAT_TODO_FILE, TICKETS_DIR, ticketFromQueueEntry, todoPriorityForTicket } from './tickets.js';
/**
* The open entries of a backlog document: markdown list items (`-`, `*`, or
* `1.`), where a task checkbox counts only while unchecked (`- [ ]`); a checked
* `- [x]` entry is done. Headings, prose, and blank lines are not entries.
*/
export function parseTodoEntries(md) {
const entries = [];
for (const line of md.split('\n')) {
const item = /^\s*(?:[-*]|\d+\.)\s+(.*)$/.exec(line);
if (!item)
continue;
const text = item[1].trim();
if (!text)
continue;
const task = /^\[([ xX])\]\s*(.*)$/.exec(text);
if (task) {
if (task[1] !== ' ')
continue; // checked off = done
if (task[2].trim())
entries.push(task[2].trim());
}
else {
entries.push(text);
}
}
return entries;
}
/**
* Append an open entry to the queue on the data branch (#1582), creating {@link FLAT_TODO_FILE}
* when the branch has none. Resolves with the file written, or `undefined` if it couldn't be.
*
* This is how a paused agent leaves word to pick itself up again (#529): the
* backlog is already the thing a later agent drains, so a resume note needs no
* machinery of its own. Never throws — it is called while an agent is already
* unwinding, and must not mask the reason it stopped.
*/
export async function appendTodoEntry(cwd, entry) {
return queueWrite(cwd, '[The Framework] queue a resume note', md => {
const separator = md === '' || md.endsWith('\n') ? '' : '\n';
return `${md}${separator}- [ ] ${entry}\n`;
});
}
/**
* Append an open entry to the queue with a priority, creating {@link FLAT_TODO_FILE} when there
* is none.
*
* The difference from {@link appendTodoEntry} is the priority placement (#1164): a dashboard
* pick (#697) lands in its `## Priority N` section rather than at the end of the file.
*/
export async function appendFlatTodoEntry(cwd, entry, priority) {
return queueWrite(cwd, '[The Framework] queue an entry', md => priority !== undefined
? insertTodoEntry(md, entry, priority)
: `${md}${md === '' || md.endsWith('\n') ? '' : '\n'}- [ ] ${entry}\n`);
}
/**
* One edit of the queue file, through the data branch's write funnel (#1582): resolve the project
* root (an agent calls this from its worktree; the data checkout lives beside the main repo),
* apply the pure edit, let the funnel commit and push it. Never throws; `undefined` means the
* write did not land.
*/
async function queueWrite(cwd, message, edit) {
const root = await dataProjectRoot(cwd).catch(() => undefined);
if (!root)
return undefined;
const result = await withDataBranch(root, message, async (dir) => {
const path = join(dir, FLAT_TODO_FILE);
const md = await readFile(path, 'utf8').catch(() => '');
await writeFile(path, edit(md), 'utf8');
});
return result.ok ? FLAT_TODO_FILE : undefined;
}
/** A `## Priority 7` heading, with whatever gloss the format's example puts after the number. */
const PRIORITY_HEADING = /^##\s+priority\s+(\d{1,2})\b/i;
/** Any second-level heading, which is where a priority section ends. */
const SECTION_HEADING = /^##\s+/;
/**
* Place an open entry in the backlog's priority section (`prompts/todo_format.md`), creating the
* section when the file has none, and return the new document.
*
* Pure, because the placement is the whole point and it is much easier to pin here than through
* the filesystem. The old behaviour was a plain append, which put a just-queued ticket at the
* *end* of the file — and since the drain preset works "the FIRST open entry" and
* {@link parseTodoEntries} reads in file order, queueing something meant it would be worked last,
* behind everything already there (#1164).
*
* Placement rules, in the order they are tried:
* - a section for this priority already exists: the entry joins the end of it, so a queue keeps
* its arrival order within a priority
* - otherwise the section is created before the first *lower*-priority section, since the format
* sorts high to low
* - with no priority sections at all, it goes above the first heading of any kind: the file's
* own sections are then unranked, and burying a deliberate pick under them is the bug
*/
export function insertTodoEntry(md, entry, priority) {
const item = `- [ ] ${entry}`;
const lines = md.split('\n');
const headings = lines
.map((line, index) => ({ index, priority: Number(PRIORITY_HEADING.exec(line)?.[1]) }))
.filter(h => Number.isFinite(h.priority));
const existing = headings.find(h => h.priority === priority);
if (existing) {
// The end of that section: the last line before the next heading that is not blank, so the
// entry lands under the section's last item rather than after its trailing blank line.
let end = lines.findIndex((line, index) => index > existing.index && SECTION_HEADING.test(line));
if (end === -1)
end = lines.length;
while (end > existing.index + 1 && lines[end - 1].trim() === '')
end--;
lines.splice(end, 0, item);
return lines.join('\n');
}
const section = [`## Priority ${priority}`, '', item, ''];
const lower = headings.find(h => h.priority < priority);
if (lower) {
lines.splice(lower.index, 0, ...section);
return lines.join('\n');
}
if (headings.length) {
// Every existing section outranks it, so it goes last -- but still as its own section.
const last = headings[headings.length - 1];
let end = lines.findIndex((line, index) => index > last.index && SECTION_HEADING.test(line));
if (end === -1)
end = lines.length;
while (end > last.index + 1 && lines[end - 1].trim() === '')
end--;
lines.splice(end, 0, '', ...section.slice(0, 3));
return lines.join('\n');
}
const firstHeading = lines.findIndex(line => SECTION_HEADING.test(line));
if (firstHeading === -1) {
const separator = md === '' || md.endsWith('\n') ? '' : '\n';
return `${md}${separator}${section.slice(0, 3).join('\n')}\n`;
}
lines.splice(firstHeading, 0, ...section);
return lines.join('\n');
}
/**
* Retire one open entry in a queue document: check its box (or give a bare bullet one, checked).
* The same "open" grammar {@link parseTodoEntries} reads (#1164/#1297), the retire half of what
* `queue-promote.ts` did before #1582 made the daemon the queue's one local writer.
*/
export function checkOffEntry(md, entry) {
const line = /^(\s*(?:[-*]|\d+\.)\s+)(?:\[([ xX])\]\s*)?(.*)$/;
return md
.split('\n')
.map(row => {
const item = line.exec(row);
if (!item || item[3]?.trim() !== entry || item[2] === 'x' || item[2] === 'X')
return row;
return `${item[1]}[x] ${item[3].trim()}`;
})
.join('\n');
}
/**
* The backlog and its open entries, read off the data branch (#1582) — the queue's one location,
* readable from the project checkout and from any agent worktree alike. Returns `undefined` when
* no queue exists or it has no open entry. Session-scoped `TODO_<slug>.agent.md` files are
* retired (#1369).
*
* `fresh: true` re-fetches the branch first: for a long-lived agent process about to act on the
* queue, where the local ref may trail what other writers pushed meanwhile.
*/
export async function findTodoBacklog(cwd, opts = {}) {
const md = await readDataFile(cwd, FLAT_TODO_FILE, opts);
if (md === undefined)
return undefined;
const entries = parseTodoEntries(md);
return entries.length ? { name: FLAT_TODO_FILE, entries } : undefined;
}
/**
* Does this session's own backlog still have open work (#1363)?
*
* Reads only `TODO_<SESSION_NAME>.agent.md` — the file the [Research] preset (and a very-large
* scope) has the agent keep for its own session. Never the global `TODO_AGENTS.md`: the queue is
* decoupled from sessions (#1390), and withholding a merge on it would mean auto-merge never
* fires while the project has any backlog at all. `false` on a missing or unreadable file, and on
* a session name that could not name a file — no pendingness known is not pendingness.
*
* TEMPORARY SAFETY BELT, built to be deleted (#1390): the agent's setReadyForMerge() is the
* authorization, and this only catches the agent declaring done while its own session file says
* otherwise. When the agent's word is deemed enough, delete this function and its single call
* site in `maybeAutoHandoff`.
*/
export async function agentTodoPending(cwd, sessionName) {
// The prompt asks for [a-z0-9-]+; anything wider (a path separator above all) names no file.
if (!sessionName || !/^[A-Za-z0-9._-]+$/.test(sessionName))
return false;
const md = await readFile(join(cwd, `TODO_${sessionName}.agent.md`), 'utf8').catch(() => undefined);
return md !== undefined && parseTodoEntries(md).length > 0;
}
/**
* The ticket the next drain agent will pick up, or `undefined` when there is none (#1117).
*
* "Next" is the first open entry of the flat backlog, because that is what the [Drain queue]
* preset says to work ("the FIRST open entry only") and {@link parseTodoEntries} returns entries
* in file order. Read from the project checkout, the same copy the sweep already consults when it
* decides whether there is anything to drain, so the entry this names is the entry that decision
* was made on.
*
* A best guess by construction: the agent reads its own worktree a moment later, and an entry
* checked off in between would move it on. Being wrong here costs a mislabelled lane on the
* Overview and nothing else — no run is started or steered by this.
*/
export async function nextQueuedTicket(cwd) {
const md = await readDataFile(cwd, FLAT_TODO_FILE);
if (md === undefined)
return undefined;
const first = parseTodoEntries(md)[0];
return first ? ticketFromQueueEntry(first) : undefined;
}
/**
* The ticket an agent started by hand is about to implement, when that agent is a drain (#1117).
*
* The daemon already does this for the sweep's own drain, off the `drains` flag on the job. An agent
* fired from the dashboard reaches the same start with none of that context, so a hand-fired drain
* showed up working on nothing: the agent implemented the ticket, and the lane it belonged in stayed
* empty. Same read as the sweep's, so both agree on which entry is next.
*
* Undefined for anything that is not a drain, and for a drain over an empty queue. The `read` seam
* is for tests; production always takes the default.
*/
export async function ticketForPrompt(prompt, cwd, read = nextQueuedTicket) {
if (!drainsQueue(prompt))
return undefined;
return read(cwd).catch(() => undefined);
}
/** The default per-agent cap on backlog entries — a backstop beside the budget cap (#322). */
export const DEFAULT_MAX_TODO_ITEMS = 25;
/** How many consecutive failed check-off writes before the loop stops rather than spins. */
const MAX_STALLS = 2;
/**
* Drive the backlog to empty: read the next open entry (fresh off the data branch), gate, prompt
* the agent to complete exactly that entry, check it off on the data branch, and repeat. The
* check-off is the framework's, not the agent's (#1582): the queue lives on a branch the agent's
* checkout does not hold, and the one writer model keeps every edit going through the same
* funnel. Caps make it safe to leave unattended (#322's concern): the agent's budget/abort
* signal ends any turn, a hard item cap bounds the agent, and two check-offs in a row failing to
* land stop the loop instead of re-working the same entry. A backlog turn is a turn like any
* other: await gates (`showChoices()` / `showMultiSelect()`) and the signals (`showMarkdown()`,
* `setSessionName()`, `setReadyForMerge()`) are honored here too.
*/
export async function runTodoLoop(opts) {
const { session, cwd, emit } = opts;
const maxItems = opts.maxItems ?? DEFAULT_MAX_TODO_ITEMS;
// One emitter for the whole backlog, so ready-for-merge fires once across every item
// and a session name only re-emits on an actual rename.
const gateDeps = {
requestChoice: opts.requestChoice,
emit,
signal: opts.signal,
emitTurnSignals: createTurnSignalEmitter(emit),
};
let completed = 0;
let file;
// The backlog emptied: announce it if we did any work, and report a clean finish.
// Both the mid-loop find and the post-loop re-check funnel through here.
const finishEmpty = () => {
if (completed > 0)
emit({ kind: 'log', message: `Backlog done: ${file ?? 'TODO'} is empty after ${completed} item(s).` });
return { completed, reason: 'empty', ...(file ? { file } : {}) };
};
for (let item = 0; item < maxItems; item++) {
if (opts.signal?.aborted)
break;
const backlog = await findTodoBacklog(cwd, { fresh: true });
if (!backlog)
return finishEmpty();
file = backlog.name;
const next = backlog.entries[0];
const preview = next.length > 100 ? `${next.slice(0, 100)}…` : next;
if (item === 0)
emit({ kind: 'log', message: `Backlog: ${backlog.name} has ${backlog.entries.length} open item(s).` });
// The per-item gate (#323): pause before starting a new entry when someone
// can answer. Interactive-only, like the plan-approval gate — a headless agent
// emits no gate and just proceeds (autopilot semantics, budget-capped).
if (opts.requestChoice) {
const picked = await requestChoices({
id: item === 0 ? 'todo-next' : `todo-next-${item}`,
title: `Start the next backlog item? (${backlog.entries.length} open)`,
options: [
{ id: 'proceed', label: `Work on: ${preview}` },
{ id: 'stop', label: 'Stop the backlog loop' },
],
recommended: 'proceed',
requestChoice: opts.requestChoice,
emit,
...(opts.signal ? { signal: opts.signal } : {}),
});
if (picked === 'stop') {
emit({ kind: 'log', message: `Backlog loop stopped by you (${backlog.entries.length} item(s) left in ${backlog.name}).` });
return { completed, reason: 'stopped', file };
}
}
emit({ kind: 'log', message: `Backlog item ${completed + 1}: ${preview}` });
// Complete exactly this entry, honoring await gates. The queue file is not the agent's to
// touch (#1582): it lives on the data branch, and the check-off below is the framework's.
const prompt = `Work on exactly this task from the project's task queue, and nothing else:\n\n${next}\n\nComplete it fully and verify your work. Do not start any other task; the framework checks this entry off the queue when the turn ends.`;
const rounds = await runAwaitRounds({ session, prompt, ...gateDeps });
completed++;
// A plan the user declined with a stop-marked answer (#358) ends the whole session, not just
// this loop — so the session does not go on to publish work that was just rejected. The caller
// aborts on `sessionStopped`; `reason` stays descriptive of the loop itself.
if (rounds.stopped) {
emit({ kind: 'log', message: `Session stopped by your answer (${backlog.entries.length} item(s) left in ${backlog.name}).` });
return { completed, reason: 'stopped', file, sessionStopped: true };
}
// Retire the entry on the data branch. `checkOffEntry` no-ops when someone else already
// retired it meanwhile — the write funnel re-reads the fresh queue either way. Retried
// inline rather than across rounds: a check-off that never lands would re-serve the same
// entry, and re-doing finished work is worse than stopping with the queue intact.
let landed;
for (let tries = 0; tries < MAX_STALLS && landed === undefined; tries++) {
landed = await queueWrite(cwd, '[The Framework] check off a worked entry', md => checkOffEntry(md, next));
}
if (landed === undefined) {
emit({ kind: 'log', message: `Backlog loop stopped: the check-off of "${preview}" could not be written after ${MAX_STALLS} attempt(s).` });
return { completed, reason: 'stalled', file };
}
}
// Aborted mid-loop (Stop button / budget cap #322): the agent is ending anyway,
// so report a clean stop without extra narration.
if (opts.signal?.aborted)
return { completed, reason: 'stopped', ...(file ? { file } : {}) };
const remaining = await findTodoBacklog(cwd);
if (!remaining)
return finishEmpty();
emit({
kind: 'log',
message: `Backlog loop stopped at the ${maxItems}-item cap; ${remaining.entries.length} item(s) left in ${remaining.name}.`,
});
return { completed, reason: 'max-items', ...(file ? { file } : {}) };
}
//# sourceMappingURL=todo-loop.js.map