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.

118 lines 7.06 kB
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