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.

563 lines 30.6 kB
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