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.
563 lines • 30.6 kB
JavaScript
import { nodeGitRunner } from '../project.js';
import { cachedPrView, cachedPrsForBranch, forgetBranchPrs, forgetPr, ghMergePr, ghPrsForBranch, nodeGhRunner, pickAgentPr, } from './gh.js';
import { parseNumstat } from './file-diff.js';
import { parsePorcelain } from './file-status.js';
import { errorMessage } from '../error-message.js';
import { legacyAgentBranchName, AGENT_BRANCH_PREFIX, LEGACY_AGENT_BRANCH_PREFIX, commitPendingWork, currentBranch, repoHasRemote, startedAtFromAgentId, FRAMEWORK_DIR } from '../store/index.js';
/**
* The branch an agent's work is on.
*
* Prefers what was recorded while the worktree existed (#799), because the built-in system prompt (#326) lets the
* agent name its own branch, which makes both derivations below a guess. They stay as a fallback
* for runs archived before the branch was recorded.
*/
export function agentBranchFor(agent) {
if (agent.branch)
return agent.branch;
// The fallbacks go through the store's own builders rather than assembling the same names a
// second time here — two spellings of one branch name is how the prefix went stale under D5.
// They use the *legacy* slashed spellings on purpose: every run they can apply to was archived
// before the branch was recorded, which predates the slash-free rename (#1581).
return agent.sessionName ? `${LEGACY_AGENT_BRANCH_PREFIX}${agent.sessionName}` : legacyAgentBranchName(agent.id);
}
/**
* The branch's PR as it applies to *this* run: the injected seam when the caller gave one, else
* the cached history filtered through {@link pickAgentPr} with the agent's start time (#1251).
*/
async function lookupAgentPr(cwd, branch, deps) {
if (deps.pr)
return { value: await deps.pr(cwd, branch).catch(() => undefined), pending: false };
const prs = await cachedPrsForBranch(cwd, branch).catch(() => ({ value: undefined, pending: false }));
return { value: prs.value ? pickAgentPr(prs.value, deps.since) : undefined, pending: prs.pending };
}
/**
* The pull request that belongs to an agent: the one it recorded (E6), read live for its state.
*
* The number is a fact about the agent, so the agent writes it down — at the moment its handoff opens
* the PR, or when the dashboard's button does after the process is gone. Every surface then reads
* the same integer instead of re-deriving it.
*
* What that replaced: a three-way branch-name ladder (the recorded branch, then the session-name
* branch, then the run-id branch, because a hands-off web agent's checkout is gone and its session
* name may be a reused pin) plus a timestamp heuristic on top of it, so that a predecessor's PR on
* a shared branch name was not mistaken for this agent's. Three sources and a guess, standing in for
* one integer nobody had written down — the same lesson the `branch` event (#1277) already learned.
*
* The *state* is still read live, because it changes without this agent doing anything: a PR merges,
* a human closes it. That read rides the PR-lookup cache (#1028), and `pending` while it is warming means the
* caller can ask again rather than render "no PR".
*/
export async function resolveAgentPr(cwd, agent, prs = cachedPrView) {
if (!agent.pr)
return { value: undefined, pending: false };
const branch = agentBranchFor(agent);
const read = await prs(cwd, branch).catch(() => ({ value: undefined, pending: false }));
// The live read is about the recorded PR's *state*; a different number on the branch is some
// other PR and never this agent's answer.
if (read.value && read.value.number === agent.pr.number)
return { value: read.value, pending: false };
// Nothing live to say, so the recorded fact stands on its own: an agent whose PR is on a branch this
// machine cannot see still has a PR, and its number and URL are what the surfaces need.
return { value: { ...agent.pr, state: read.pending ? 'OPEN' : 'UNKNOWN', title: '' }, pending: read.pending };
}
/**
* Merge a finished session's open PR (#1391): the Merge action, pressed by a human.
*
* The direct answer to the withheld-merge ending (#1363): a session whose agent never signalled
* ready-for-merge leaves a draft PR behind, and this is the human saying "it's good, land it".
* `ghMergePr` marks a draft ready on the way, for exactly that case. Refuses when the agent has no
* PR or it is no longer open — "already merged" is an answer, not an action.
*/
export async function mergeAgentPr(cwd, agent, deps = {}) {
const pr = (await resolveAgentPr(cwd, agent, deps.prs)).value;
if (!pr)
return { ok: false, error: 'this session has no pull request to merge' };
if (pr.state !== 'OPEN')
return { ok: false, error: `this session's PR is already ${pr.state.toLowerCase()}` };
const merged = await ghMergePr(cwd, pr.number, deps.gh);
if (merged.outcome === 'failed')
return { ok: false, error: merged.error };
// The PR's cached state just changed, so the branch's cached read must go or the bar keeps
// offering a merge for a PR that landed (#1028).
const branch = agentBranchFor(agent);
forgetPr(cwd, branch);
forgetBranchPrs(cwd, branch);
return { ok: true, url: pr.url, number: pr.number };
}
/**
* Whether a branch is one a session made, rather than one the user did.
*
* Only a naming convention, so it is a guess for the case #326 allows — the agent picking its own
* branch name. Every caller uses it to decide how loudly to surface something, never to act.
*/
export function isAgentBranch(branch) {
return Boolean(branch && (branch.startsWith(AGENT_BRANCH_PREFIX) || branch.startsWith(LEGACY_AGENT_BRANCH_PREFIX)));
}
/** `git` that resolves to '' instead of rejecting, for reads where "no answer" is a fine answer. */
function soft(git, cwd) {
return args => git(args, cwd).catch(() => '');
}
/** The repo's default branch: what the remote points HEAD at, else the first local conventional one. */
async function detectBase(agent) {
const head = (await agent(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).trim();
if (head)
return head;
for (const name of ['main', 'master']) {
if ((await agent(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])).trim())
return name;
}
return undefined;
}
/** A subject can hold anything, so the fields are unit-separated rather than space-split. */
const SEP = String.fromCharCode(31);
/** Parse `git log --format=%H%x1f%s`. */
function parseCommits(out) {
return out
.split('\n')
.filter(line => line.includes(SEP))
.map(line => {
const [sha = '', subject = ''] = line.split(SEP);
return { sha, short: sha.slice(0, 7), subject };
});
}
/** `git diff --numstat` as {@link HandoffFile}s, via the shared parser in file-diff.ts. */
function parseHandoffFiles(out) {
return parseNumstat(out).map(({ path, added, removed, binary }) => ({ path, insertions: added, deletions: removed, binary }));
}
/** The framework's own paper trail (#1291): the agent archive, plus pre-B3 records (conversations, LOGS.md). */
function isBookkeepingPath(path) {
return path === FRAMEWORK_DIR || path.startsWith(`${FRAMEWORK_DIR}/`);
}
/**
* Read what a finished session left behind, from the project repo, for `branch`.
*
* Returns undefined only when `cwd` is not a git repo at all. A branch that no longer exists
* still returns a handoff (with `exists: false`), because "that branch is gone" is itself the
* answer the dashboard needs to show.
*/
export async function readAgentHandoff(cwd, branch, deps = {}) {
const git = deps.git ?? nodeGitRunner();
const agent = soft(git, cwd);
// Not a repo (or git is unusable): nothing here is answerable.
if (!(await git(['rev-parse', '--git-dir'], cwd).then(() => true).catch(() => false)))
return undefined;
const tip = (await agent(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).trim();
const hasRemote = await repoHasRemote(cwd, git);
const pending = await countPendingWork(git, deps.checkout);
if (!tip) {
// The branch being gone locally does not mean the work is: a hands-off web agent pushes its
// branch and opens its PR remotely, and a merged branch gets deleted. The PR is a remote
// question, so it is still answerable — and it is the one thing left worth showing (#1255).
const pr = await lookupAgentPr(cwd, branch, deps);
return {
branch,
exists: false,
commits: [],
files: [],
insertions: 0,
deletions: 0,
empty: true,
hasRemote,
pushed: false,
merged: false,
...(pr.value ? { pr: pr.value } : {}),
...(pr.pending ? { prPending: true } : {}),
...pending,
};
}
const base = await detectBase(agent);
// Two ranges, because git's two spellings mean opposite things here and only one is right for
// each question (#1164/#1173).
//
// `base..branch` is the branch's OWN commits, which is what "what did this session produce"
// asks. `base...branch` in `git log` is the SYMMETRIC difference, so it also lists commits that
// are only on the base — exactly the thing the comment below says not to count. A session whose
// work is already merged then reported commits it did not make, `empty` stayed false, and the
// dashboard offered an Open PR that GitHub refuses with "No commits between main and <branch>".
//
// For the diff the three-dot form IS the right one: it is the change since the branch point,
// rather than a comparison against a base that has moved on since.
const logRange = base ? `${base}..${branch}` : undefined;
const diffRange = base ? `${base}...${branch}` : undefined;
const [commitsOut, numstatOut, remoteTip, mergedOut] = await Promise.all([
logRange ? agent(['log', '--format=%H%x1f%s', logRange]) : Promise.resolve(''),
diffRange ? agent(['diff', '--numstat', diffRange]) : Promise.resolve(''),
hasRemote ? agent(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`]) : Promise.resolve(''),
base ? agent(['branch', '--list', '--merged', base, branch]) : Promise.resolve(''),
]);
const commits = parseCommits(commitsOut);
const files = parseHandoffFiles(numstatOut);
// Read through the cache and allowed to arrive late (#1028): the commits, the files and
// whether the branch is pushed are all local git, and none of them should wait on `gh`.
const pr = await lookupAgentPr(cwd, branch, deps);
return {
branch,
exists: true,
...(base ? { base } : {}),
commits,
files,
insertions: files.reduce((sum, f) => sum + f.insertions, 0),
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
// A session that changed nothing is a real outcome, not an error: it gets said, not shown as
// an empty branch with buttons that would push nothing. Bookkeeping-only counts as nothing
// (#1291): every agent's branch carries the framework's own records — the pre-work (#326) commit
// sweeps in the conversation file the daemon just wrote — and publishing those alone produced
// junk PRs of pure paper trail. The files decide, not the commits: a branch of bookkeeping
// sweeps has commits and still nothing to hand off.
empty: commits.length === 0 || files.every(file => isBookkeepingPath(file.path)),
hasRemote,
pushed: remoteTip.trim() === tip,
merged: mergedOut.trim().length > 0,
...(pr.value ? { pr: pr.value } : {}),
...(pr.pending ? { prPending: true } : {}),
...pending,
};
}
/**
* The files the session left uncommitted in its own checkout, as a spreadable field.
*
* Absent rather than `[]` when no checkout was given (or git could not answer): "nobody asked" and
* "asked, nothing pending" are different answers, and only the second one may be shown as a clean
* tree.
*/
async function countPendingWork(git, checkout) {
if (!checkout)
return {};
const status = await git(['status', '--porcelain'], checkout).catch(() => undefined);
if (status === undefined)
return {};
return { pendingFiles: parsePorcelain(status).map(entry => entry.path) };
}
/**
* Commit what a session left uncommitted, so what it did is what gets handed off (#1173).
*
* The automatic handoff commits the session's leftovers on the agent's way out, but that happens
* when the agent process exits, and the finishing step is offered as soon as the agent settles
* (#1178), which for a session left open for another turn is much earlier. Pressing the button is
* the same instruction given by hand, so it sweeps the same leftovers into what it publishes. The
* button only shows for a branch that already carries commits (#1173): a no-diff branch names its
* uncommitted work instead of offering a step, so this never turns "nothing committed" into a PR
* by itself.
*
* Two guards, because both failure modes end with the user's own work committed for them: the
* checkout has to be the session's own (#453) rather than the project root that `resolveAgentCheckout`
* falls back to once a worktree is gone, and it has to be sitting on the session's branch.
*
* Returns whether the handoff may go ahead: true when there was nothing to do, when the guards say
* this is not ours to commit, or when the commit succeeded.
*/
export async function commitAgentWork(checkout, projectCwd, branch, git = nodeGitRunner()) {
if (checkout === projectCwd)
return true;
if ((await currentBranch(checkout, git)) !== branch)
return true;
return commitPendingWork(checkout, git);
}
/**
* Push a finished session's branch to `origin`.
*
* Publishing the agent's work under the user's name is the user's call, but since #1102 that call
* is made once, up front, by a checkbox that is armed by default, rather than re-taken by hand at
* the end of every session. The click is still here for a session that opted out, and it is what
* a failed auto-push falls back to.
*/
export async function pushAgentBranch(cwd, branch, git = nodeGitRunner()) {
try {
await git(['push', '--set-upstream', 'origin', branch], cwd);
return { ok: true };
}
catch (err) {
return { ok: false, error: gitReason(err) };
}
}
/**
* {@link AgentHandoff.base} as a base a PR can actually be opened against.
*
* The field holds a git ref, because that is what every other use of it needs: `detectBase` reads
* `refs/remotes/origin/HEAD`, so it is `origin/main`, and the log range and merged check are both
* asking git a question about a remote-tracking ref. `gh pr create --base` is asking GitHub for a
* *branch on the remote*, and rejects `origin/main` with "Base ref must be a branch".
*
* So the conversion belongs at the `gh` boundary rather than in the field. Stripping `origin/`
* matches what the rest of this module already assumes: the remote is `origin` (`pushAgentBranch`
* pushes there, `detectBase` reads its HEAD).
*/
export function prBaseName(base) {
return base.startsWith('origin/') ? base.slice('origin/'.length) : base;
}
/**
* The line of a failed git invocation worth showing.
*
* `execFile` rejects with "Command failed: git push ..." and buries git's own `fatal:` line
* further down, which in a one-line panel means the user reads the command back instead of the
* reason it failed.
*/
export function gitReason(err) {
const message = errorMessage(err);
const lines = message.split('\n').map(line => line.trim()).filter(Boolean);
return lines.find(line => /^(fatal|error|remote):/i.test(line)) ?? lines[0] ?? 'git failed';
}
/**
* Open a PR for a finished session's branch, pushing it first when the remote does not have it.
*
* The button opens it ready for review, because a PR a human asked for by name is asking for
* review. {@link PullRequestDraft.draft} is the auto-handoff case, which is not.
*/
export async function openBranchPullRequest(cwd, branch, draft, deps = {}) {
const git = deps.git ?? nodeGitRunner();
const gh = deps.gh ?? nodeGhRunner();
// gh refuses to open a PR for a branch the remote has never seen, so the push is part of the
// action rather than a thing the user has to remember to do first.
const pushed = await pushAgentBranch(cwd, branch, git);
if (!pushed.ok)
return pushed;
try {
const args = ['pr', 'create', '--head', branch, '--title', draft.title, '--body', draft.body];
if (draft.base)
args.push('--base', prBaseName(draft.base));
if (draft.draft)
args.push('--draft');
const out = (await gh(args, cwd)).trim();
// The branch has a PR now, so the cached "no PR" must go or the bar would keep offering to
// open one for the next minute (#1028). Both caches: the single-PR view and the history.
forgetPr(cwd, branch);
forgetBranchPrs(cwd, branch);
// gh prints the new PR's URL as its last line, and the number is its last path segment.
const url = out.split('\n').filter(Boolean).at(-1);
if (!url)
return { ok: true };
const number = prNumberFromUrl(url);
return { ok: true, url, ...(number !== undefined ? { number } : {}) };
}
catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
/**
* Open a draft PR for a branch that exists only on the remote (#1601): a cloud session's own
* `claude/*` branch was pushed from a VM this machine never sees, so there is nothing to push
* here — `gh pr create --head` against the remote branch is the whole action, and gh's default
* base (the repo's default branch) is the right one. Draft for the same reason the auto-handoff
* opens drafts: a PR the framework opens by itself must not put a review request in anyone's
* inbox, and the interventions queue keeps listing a session's draft.
*/
export async function openRemoteBranchPullRequest(cwd, agent, branch, deps = {}) {
const gh = deps.gh ?? nodeGhRunner();
try {
const out = (await gh(['pr', 'create', '--head', branch, '--title', agentPrTitle(agent), '--body', agentPrBody(agent), '--draft'], cwd)).trim();
forgetPr(cwd, branch);
forgetBranchPrs(cwd, branch);
const url = out.split('\n').filter(Boolean).at(-1);
if (!url)
return { ok: true };
const number = prNumberFromUrl(url);
return { ok: true, url, ...(number !== undefined ? { number } : {}) };
}
catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
/**
* Whether the session kept committing after its PR merged or closed (#1512): the PR carries a
* head, the branch has a tip, and they disagree. False for an open PR (pushed commits still land
* on it), for a headless read (an older cache or injected lookup — never risk a duplicate PR on
* a guess), and for a gone branch (no tip to compare; the PR stays the best answer, #1255).
*/
function movedPastPr(state) {
if (!state.pr || state.pr.state === 'OPEN')
return false;
const tip = state.commits[0]?.sha;
return Boolean(state.pr.headRefOid && tip && state.pr.headRefOid !== tip);
}
/**
* Open a PR for a finished session, deciding from what the agent recorded which cases should not
* open one. Reads the branch's handoff first: a branch that no longer exists, or a session that
* changed nothing, is a clear error rather than an empty PR, and a branch that already has a PR
* returns that one. Title is the session name (else the intent's first line, else the id); body
* is the intent plus which session did it. This is the handoff decision the dashboard's
* open-PR button offers; the RPC layer only resolves which run it is about.
*/
export async function openAgentPullRequest(cwd, agent, options = {}) {
const branch = agentBranchFor(agent);
const handoff = await readAgentHandoff(cwd, branch, { since: agent.startedAt }).catch(() => undefined);
// The agent's PR first, even when its branch is gone locally: a hands-off web agent's branch only
// ever existed on the remote, and its PR is the answer the button exists to give (#1255).
// Unless the session demonstrably kept committing after that PR merged or closed (#1512) —
// then the old PR is not the answer, the new work needs its own.
if (handoff?.pr && !movedPastPr(handoff))
return { ok: true, url: handoff.pr.url, number: handoff.pr.number };
if (handoff && !handoff.exists)
return { ok: false, error: `branch ${branch} no longer exists` };
// Refuse rather than open an empty PR: a session that changed nothing has nothing to hand off.
if (handoff?.empty)
return { ok: false, error: 'this session produced no commits to open a PR for' };
return openBranchPullRequest(cwd, branch, {
title: agentPrTitle(agent),
body: agentPrBody(agent),
...(handoff?.base ? { base: handoff.base } : {}),
...(options.draft ? { draft: true } : {}),
});
}
/** Both halves armed — the default a session starts from. */
const ARMED_HANDOFF = { push: true, pr: true };
/**
* Whether an armed merge may actually run (#1363), and if not, why.
*
* The rule settled on #1390: config *arms* the merge, the agent *authorizes* it. Landing on the
* default branch unattended takes (a) the agent having declared the session done via
* setReadyForMerge() — the same signal the on-before-mergeable step requires — and (b) the
* framework not already knowing of work pending in this session (its own TODO file; never the
* global queue, which is decoupled from sessions). A withheld merge is not a failed handoff:
* push and PR go ahead, the PR just opens as a draft for a human.
*
* (b) is a temporary safety belt: the agent's word should ultimately be enough. Deleting it means
* deleting `agentTodoOpen` here and `agentTodoPending` in todo-loop.ts.
*/
export function withheldMerge(deps) {
if (!deps.readyForMerge)
return 'not-ready-for-merge';
if (deps.agentTodoOpen)
return 'session-todo-open';
return undefined;
}
/**
* Do the end-of-session handoff a session was left armed for (#1102): push the branch, open a
* draft PR for it, or both.
*
* Reads the branch first and refuses on everything that is not a clean hand-off — a branch that is
* gone, a session that committed nothing, a repo with no remote, a branch whose PR already covers
* everything on it. Those are the cases where doing it anyway would produce a confusing artefact
* rather than help. A merged PR the session kept working past is NOT one of them (#1512): the
* work after the merge gets a fresh PR, or it reaches nobody.
*
* The PR is a draft on purpose. Opening one by itself at the end of every session must not put a
* review request in anyone's inbox, and the interventions queue keeps listing a session's draft
* so the work still comes back to the human.
*/
export async function agentAutoHandoff(cwd, agent, intent, deps = {}) {
if (!intent.push && !intent.pr)
return { outcome: 'skipped', reason: 'not-armed' };
const branch = agentBranchFor(agent);
const since = agent.startedAt ?? startedAtFromAgentId(agent.id);
const { gh, ...readDeps } = deps;
// The UNcached PR lookup, deliberately. The dashboard's cache answers `prPending` rather than
// yes-or-no (#1028), which is right for a panel repainting every 15s and wrong here: "not known
// yet" would read as "no PR" and this would open a second one. Proved against a real remote —
// only `gh` refusing the duplicate stopped it. This runs once, at the end of a session, so it
// can afford to wait for a real answer. Filtered by the agent's start time (#1251): a merged PR
// from an earlier agent on the same branch name must not stop this agent from opening its own.
// `latest` order (#1512): the decision below compares the branch tip against the PR's head, and
// only the last PR that saw the branch answers that — against the first, work a second PR
// already landed would read as unlanded and reopen.
const agentPr = async (c, b) => pickAgentPr(await ghPrsForBranch(c, b), since, 'latest');
const state = await readAgentHandoff(cwd, branch, { pr: agentPr, ...readDeps }).catch(() => undefined);
if (!state || !state.exists)
return { outcome: 'skipped', reason: 'branch-gone' };
if (state.empty)
return { outcome: 'skipped', reason: 'no-commits' };
if (!state.hasRemote)
return { outcome: 'skipped', reason: 'no-remote' };
// An OPEN PR covers both halves: it means the branch is published and the human has a place
// to answer. Opening a second one is the one mistake this must never make. An armed merge
// (#1216) still applies to the open PR — this is a rerun or a restart finding the PR its
// predecessor opened, and the merge is the half that has not happened yet.
if (state.pr?.state === 'OPEN') {
if (intent.merge) {
// `watch` mode (#1418), like the freshly-opened path below: an auto merge must wait for the
// PR's checks rather than land on the direct fallback before they run (#1406).
return { outcome: 'skipped', reason: 'already-open', merge: await ghMergePr(cwd, state.pr.number, gh, { whenUnarmed: 'watch' }) };
}
return { outcome: 'skipped', reason: 'already-open' };
}
// A merged or closed PR only covers the branch up to the head it carried (#1512). A tip it
// already landed means everything reached the human — done, not blocked. A tip past it means
// the session kept working after the PR closed, and refusing here is how that work reached
// nobody: fall through and open a fresh PR for it.
if (state.pr && !movedPastPr(state)) {
return { outcome: 'skipped', reason: 'already-landed' };
}
if (intent.pr) {
// `openBranchPullRequest` pushes first, so the PR half subsumes the push half.
const opened = await openBranchPullRequest(cwd, branch, {
title: agentPrTitle(agent),
body: agentPrBody(agent),
// GitHub refuses to merge or auto-merge a draft, so an armed merge (#1216) opens the PR
// ready: its review happened on the queue before the agent, which is the same reason the
// merge is armed at all. Draft stays the default for PRs a human is meant to look at.
draft: !intent.merge,
...(state.base ? { base: state.base } : {}),
}, { ...(readDeps.git ? { git: readDeps.git } : {}), ...(gh ? { gh } : {}) });
if (!opened.ok)
return { outcome: 'failed', step: 'pr', error: opened.error };
// The merge half (#1216), only after the PR half succeeded. The number comes off the URL gh
// just printed; the lookup is the fallback for a gh that answered without one. Failing to
// resolve a number is a reported merge failure, never a failed handoff — the PR is there.
const merge = intent.merge
? await (async () => {
const lookup = readDeps.pr ?? agentPr;
const number = prNumberFromUrl(opened.url) ?? (await lookup(cwd, branch).catch(() => undefined))?.number;
// `watch` mode (#1418): where GitHub cannot arm the merge, a just-opened PR defers to
// the daemon's CI watch instead of the direct fallback that landed it before its first
// check ran (#1406).
return number !== undefined
? ghMergePr(cwd, number, gh, { whenUnarmed: 'watch' })
: { outcome: 'failed', error: 'could not resolve the PR number to merge' };
})()
: undefined;
return {
outcome: 'done',
pushed: true,
...(opened.url ? { url: opened.url } : {}),
...(opened.number !== undefined ? { number: opened.number } : {}),
...(merge ? { merge } : {}),
};
}
if (state.pushed)
return { outcome: 'skipped', reason: 'already-pushed' };
const pushed = await pushAgentBranch(cwd, branch, readDeps.git);
if (!pushed.ok)
return { outcome: 'failed', step: 'push', error: pushed.error };
return { outcome: 'done', pushed: true };
}
/**
* The PR title for a session (#1102), with the ticket's issue reference riding along (#1334).
*
* Three rungs, each a name for the work the session did: what the agent called it in its
* `open-pr` block (#1618), else the session's own name, else the session id — which says little,
* but says it honestly.
*
* The prompt the session was given is not among them. It used to be, cut to 72 characters, and a
* squash merge made that permanent: `main` ended up carrying instructions truncated mid-sentence
* as commit subjects, which describe neither what changed nor even a whole thought (#1618).
*/
function agentPrTitle(agent) {
const title = agent.prTitle ?? agent.sessionName ?? `Session ${agent.id}`;
return agent.fixes ? `${title} (fix ${agent.fixes})` : title;
}
/**
* The PR number out of the URL `gh pr create` prints, e.g. `…/pull/123` (#1216).
*
* Parsed rather than asked for in a second `gh` call: the create already told us, and E6 is about
* recording the number we were told rather than re-deriving it later.
*/
function prNumberFromUrl(url) {
const match = url?.match(/\/pull\/(\d+)(?:$|[/?#])/);
return match ? Number(match[1]) : undefined;
}
/**
* The PR body: what the agent said about the work, else what was asked for — and which session
* did it either way.
*
* The agent's own description wins where it wrote one (#1567), because it describes what the
* change turned out to be; the intent only says what was asked at the start, which is the best
* the framework can do by itself.
*/
function agentPrBody(agent) {
const lines = [];
const opening = agent.description?.trim() || agent.intent?.trim();
if (opening)
lines.push(opening, '');
lines.push(`Opened from The Framework session \`${agent.sessionName ?? agent.id}\`.`);
return lines.join('\n');
}
//# sourceMappingURL=agent-handoff.js.map