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.
118 lines • 7.06 kB
JavaScript
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { nodeGitRunner } from './project.js';
import { FLAT_TODO_FILE } from './tickets.js';
import { errorMessage } from './error-message.js';
/** The commit message a promotion writes. Names the agent so the history says where it came from. */
function promotionMessage(agentId) {
return `[The Framework] queue updates from ${agentId}`;
}
/**
* Copy `TODO_AGENTS.md` from a finished agent's branch into the project checkout and commit it.
*
* Skips, rather than forcing, when:
* - the agent recorded no branch (nothing to read from)
* - the branch has no queue file, or it matches the checkout already (nothing to do)
* - the checkout has uncommitted changes to the queue file — a human is mid-edit, and their work
* outranks an unattended tidy-up
*
* Never throws: this runs on a background tick with nothing to catch it.
*/
export async function promoteQueue(projectCwd, agent, git = nodeGitRunner(), write = (path, content) => writeFile(path, content, 'utf8')) {
const branch = agent.branch;
if (!branch)
return { promoted: false, reason: 'the run recorded no branch' };
try {
// The queue as the agent left it. An agent that never touched it has no such path on the branch.
const fromBranch = await git(['show', `${branch}:${FLAT_TODO_FILE}`], projectCwd).catch(() => undefined);
if (fromBranch === undefined)
return { promoted: false, reason: 'the run left no queue file on its branch' };
const inCheckout = await git(['show', `HEAD:${FLAT_TODO_FILE}`], projectCwd).catch(() => '');
if (fromBranch === inCheckout)
return { promoted: false, reason: 'the queue is already up to date' };
// A dirty queue file means someone is editing it by hand right now. Leave it alone; the next
// tick will try again, and until then auto PM simply does not start more work.
const dirty = (await git(['status', '--porcelain', '--', FLAT_TODO_FILE], projectCwd)).trim();
if (dirty)
return { promoted: false, reason: 'the checkout has uncommitted queue changes', retry: true };
// An agent pinned to one entry (#1204) lands that entry, not its whole file: with several drains
// in flight the wholesale copy below would carry each agent's stale view of every *other* entry,
// and the last promotion of the tick would un-check whatever the earlier ones had just retired.
if (agent.entry !== undefined) {
// The queue where the agent forked, so an entry it *added* can be told from one somebody
// *removed* while it worked. Both look alike from the branch alone — present there, absent
// here — and `todo_format.md` makes removal the way to retire an entry, so guessing wrong
// resurrects work a human deliberately struck off. No merge base (a rewritten history, say)
// means no additions land: the check-off is the part that must not be lost.
const mergeBase = (await git(['merge-base', branch, 'HEAD'], projectCwd).catch(() => '')).trim();
const atBase = mergeBase
? await git(['show', `${mergeBase}:${FLAT_TODO_FILE}`], projectCwd).catch(() => undefined)
: undefined;
const landed = landPinnedEntry(inCheckout, fromBranch, agent.entry, atBase);
if (landed === inCheckout)
return { promoted: false, reason: 'the run left nothing to land on the queue' };
await write(join(projectCwd, FLAT_TODO_FILE), landed);
await git(['add', '--', FLAT_TODO_FILE], projectCwd);
await git(['commit', '-m', promotionMessage(agent.id), '--', FLAT_TODO_FILE], projectCwd);
return { promoted: true, branch };
}
// `checkout <branch> -- <path>` writes the file and stages it in one step, touching nothing
// else in the tree. The commit is pathspec-scoped for the same reason: whatever else is
// staged in the user's checkout is theirs and must not ride along.
await git(['checkout', branch, '--', FLAT_TODO_FILE], projectCwd);
await git(['commit', '-m', promotionMessage(agent.id), '--', FLAT_TODO_FILE], projectCwd);
return { promoted: true, branch };
}
catch (err) {
return { promoted: false, reason: errorMessage(err) };
}
}
/** One markdown list item, by the grammar `parseTodoEntries` reads. */
const ENTRY_LINE = /^(\s*(?:[-*]|\d+\.)\s+)(?:\[([ xX])\]\s*)?(.*)$/;
/** Every entry the document names, checked or not, so a re-add can tell "new" from "already there". */
function entryTexts(md) {
const texts = new Set();
for (const line of md.split('\n')) {
const text = ENTRY_LINE.exec(line)?.[3]?.trim();
if (text)
texts.add(text);
}
return texts;
}
/**
* Land what a drain agent pinned to one entry actually did (#1204): retire that entry, and keep any
* follow-ups it queued.
*
* Additive by construction, which is what makes it safe to run concurrently: it only ever checks a
* box or appends a line. It never unchecks, never removes, and never reorders, so two drains
* landing in either order compose, and the worst a wrong guess can do is leave a duplicate line
* for a human to delete rather than silently send an agent to redo finished work.
*
* A follow-up is an entry the agent's branch has that `atBase` did not: written during the agent. The
* fork point is what tells that from an entry somebody *removed* meanwhile, which looks identical
* from the branch alone and which `todo_format.md` makes the ordinary way to retire an entry. With
* no fork point to compare against, nothing is added -- resurrecting struck-off work is worse than
* leaving a follow-up on the branch, and the check-off still lands either way.
*/
export function landPinnedEntry(inCheckout, fromBranch, entry, atBase) {
const lines = inCheckout.split('\n').map(line => {
const item = ENTRY_LINE.exec(line);
// Retire any *open* entry that matches — an empty `[ ]` box or a no-checkbox bullet alike,
// the same "open" grammar `parseTodoEntries` uses (#1164/#1297).
// Skipping the no-checkbox form left it open, so the sweep re-drained it after the PR closed.
if (!item || item[3]?.trim() !== entry || item[2] === 'x' || item[2] === 'X')
return line;
return `${item[1]}[x] ${item[3].trim()}`;
});
if (atBase === undefined)
return lines.join('\n');
const known = entryTexts(inCheckout);
const base = entryTexts(atBase);
const added = [...entryTexts(fromBranch)].filter(text => !known.has(text) && !base.has(text));
if (!added.length)
return lines.join('\n');
const body = lines.join('\n');
const separator = body === '' || body.endsWith('\n') ? '' : '\n';
return `${body}${separator}${added.map(text => `- [ ] ${text}`).join('\n')}\n`;
}
//# sourceMappingURL=queue-promote.js.map