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.

499 lines 28.5 kB
import type { QuotaBoundaryStatus } from './quota-boundary.js'; import type { AutoHandoffSkip } from './events.js'; /** * Auto PM (#685): spend leftover subscription quota on product management instead of * letting it expire. While the account is still under its quota boundary (#879) and nobody * is at the keyboard, the daemon runs the cycle by itself: it works the agent queue down entry by entry (#855), * and once that is empty it refills it — triaging tickets, then spiking and planning * the ones that have neither yet. * * The whole feature is one policy question ("is now a good time to spend tokens on our * own roadmap?"), so that question lives here as a pure function and the daemon only * supplies the readings. #298 is the parent idea (background jobs / "max out the usage"), * and #879 defines the boundary this reads. */ /** How often the daemon re-asks {@link autoPmDecision}. */ export declare const DEFAULT_AUTO_PM_INTERVAL_MS: number; /** * How long a project is left alone after an auto agent is started for it. A spawned agent * takes a moment to appear in the daemon's live-run map, and without this the next tick * would see "nothing running, queue still empty" and start a second one. */ export declare const DEFAULT_AUTO_PM_COOLDOWN_MS: number; /** What the policy was told about one project at one moment. */ export interface AutoPmInputs { /** The `autoPm` preference. Off = the feature does nothing at all. */ enabled: boolean; /** * Whether the project's agent queue (`TODO_AGENTS.md`) has no open entry left, or `undefined` * when it could not be read. Unreadable is not empty and not full: it fails closed, because * since #855 both answers now *start* something and only "we could not tell" does not. */ backlogEmpty: boolean | undefined; /** Live agents on this project, measured against {@link AutoPmInputs.concurrency}. */ activeAgents: number; /** * How many agents the routine may keep going on this project at once (#1204); * {@link DEFAULT_AUTO_PM_CONCURRENCY} when unset. Floored at one, because zero concurrent * agents is what `enabled: false` already spells. */ concurrency?: number; /** Where the account stands against its quota boundary, or `undefined` when it could not be read. */ quota: QuotaBoundaryStatus | undefined; /** Milliseconds since this project was last auto-started, or `undefined` if it never was. */ sinceLastStartMs?: number; /** Override {@link DEFAULT_AUTO_PM_COOLDOWN_MS}. */ cooldownMs?: number; } /** Why the sweep is not starting anything. Logged, so it reads as a sentence. */ export type AutoPmRefusal = { start: false; reason: string; }; /** * Which half of the cycle a start belongs to (#855). `drain` works an entry the queue already * holds; `pm` puts new work in it. The queue decides: standing work is spent before more is made. */ export type AutoPmMode = 'drain' | 'pm'; /** Whether the budget allows spending unasked at all, before asking what to spend it on. */ export type QuotaDecision = { start: true; } | AutoPmRefusal; /** Start (and at what), or the reason not to. */ export type AutoPmDecision = { start: true; mode: AutoPmMode; } | AutoPmRefusal; /** * Whether the budget allows spending unasked. * * The gate is the quota boundary (#879): the pro-rated share of the week's allowance elapsed so * far, rising continuously with the clock (#960 Edit), so auto PM spends up to that line and * stands down at it. Work the user asks for is free to cross it and borrow against the days still * to come; work nobody asked for is exactly what the line is there to stop. * * **It fails closed on a quota it cannot read, and that is the opposite of the per-agent guard.** * #519 settled that an unreadable quota must never *stop* the user's own work, so * `startConsumptionGuard` fails open. Quietly burning a subscription on work nobody asked for * is a far worse failure than skipping a tick. * * Reading the account's own week also means a restarted daemon is not blind: the figure is * absolute and complete, unlike the delta meter this replaced, which reported zero consumed * after a restart however much the account had spent (#848). */ export declare function quotaHeadroom(quota: QuotaBoundaryStatus | undefined): QuotaDecision; /** * Whether to start a PM agent for one project right now. Every condition is a reason to * *not* spend the user's quota, checked cheapest first so the common "someone is working" * case never reaches the meter. */ export declare function autoPmDecision(input: AutoPmInputs): AutoPmDecision; /** * One thing auto PM knows how to do while the machine is idle (#773). * * The jobs form a cycle, and the order matters: triage turns tickets into queued work, [Plan * tickets] turns the rest into plans. Once a job queues something the sweep switches to draining it * (#855), and the rotation resumes where it left off once the queue is empty again. */ export interface AutoPmJob { /** Stable id: the rotation and the opt-out list (#1209) key on it. */ name: string; /** The prompt to run, verbatim. */ prompt: string; /** * A line saying what the job does, wherever its {@link label} does not already: under the label * in the routines list, and as the log line's wording. Only the maintenance sweep carries one -- * "Maintenance" names its preset rather than the work -- while the other routines' labels read * as what they do, so their rows stay one line and their log lines say the label itself rather * than the same thing twice. Data on the job rather than a name matched in the dashboard, so a * rename cannot quietly move the line around. */ describe?: string; /** * The user-facing name, for a surface that lists the routines (#1159). Read off the preset the * job fires rather than written again here, so a relabelled preset relabels its routine. */ label?: string; /** * The preset's own one-line "what this does" (#1506), for a surface that has to say what a click * is about to spend an agent on before it spends it. Read off the preset like * {@link AutoPmJob.label}, so the sentence the launcher shows for a preset and the sentence the * routines list shows for its routine are the same sentence. Absent for a preset without one. */ tooltip?: string | undefined; /** * This job works an entry already on the queue, rather than putting entries on it (#1117). * * Only the draining job has a specific piece of work it is about to pick up, so only it can be * told which ticket that is. Declared here rather than matched on {@link AutoPmJob.name} at the * call site, so the job says what it does and a rename cannot quietly unhook it. */ drains?: boolean; /** * The one queue entry a {@link AutoPmJob.drains} job is pinned to (#1204). Set only on the * per-start variants {@link pinnedDrainJob} builds: the catalog's own drain job carries none, * since which entry is next is known only at the moment the sweep starts one. */ entry?: string; /** * The branch this job's prompt pins via its constant session name, when it does (#1293). The * triage prompts abort when `the-framework/<SESSION_NAME>` already exists, so a leftover branch * whose PR was closed or merged jams the routine forever. Declared as data on the job, like * {@link AutoPmJob.drains}, so the sweep can release the stale name before firing without * matching on {@link AutoPmJob.name} at the call site. */ pinnedBranch?: string; /** * Merge this job's PR once its agent opens it (#1216). Set on the drain job: what it implements * has already been triaged as consensual, quick-win work a human could have vetoed on the * queue, so its PR is the one kind whose review happened before the agent. Declared as data on * the job for the same no-name-matching reason as {@link AutoPmJob.drains}. */ autoMerge?: boolean; /** * This rotation job may fan out to several agents pinned one ticket each (#1327). Only * [Plan tickets] declares it: unlike the other rotation jobs it writes per-ticket sibling files * rather than rewriting the shared queue document, so concurrent copies do disjoint work and * land disjoint edits — the exact property that already lets draining fan out. Declared as data * on the job for the same no-name-matching reason as {@link AutoPmJob.drains}. */ fansOut?: boolean; /** * The one ticket a fanned-out job is pinned to (#1327), as its filename inside `tickets/`. Set * only on the per-start variants {@link pinnedPlanJob} builds, like {@link AutoPmJob.entry} is * for drains: which tickets are open is known only at the moment the sweep locks them. */ ticket?: string; /** * The `.lock.md` claim the sweep minted for this start (#1420/#1583), on both pinned variants. * What lets the sweep free the claim itself when the agent settles with nothing to hand off: * the agent's PR is what normally deletes the lock, and an agent that never made a commit is * never opening one. */ claim?: PlanAssignment; } /** One fanned-out agent's claim (#1327): the ticket it is pinned to, and the id its lock names. */ export interface PlanAssignment { /** The ticket's filename inside `tickets/`. */ ticket: string; /** What the `.lock.md`'s `CLAIMED: <AGENT_ID>` line carries (#1420), so an agent can tell its * own claim from another's. Generated by the sweep, since no agent id exists before the agent * starts. */ agentId: string; } /** * A drain job pinned to one named queue entry (#1204). * * With a single agent the stock prompt ("the FIRST open entry") is exact. With several going at * once it is a collision: every drain forks the same checkout, so every one of them reads the same * first entry and implements it as many times over. Naming the entry is what makes a batch of * drains work on disjoint things. * * The prompt also tells the agent to stop when the entry is already checked off or gone: the * assignment is a snapshot, and a human may retire the entry between the sweep's read and the * run's own. * * When the entry links back to a ticket the sweep has claimed (#1420), the agent is also told * which claim is its own — the same contract {@link pinnedPlanJob} carries, because the same * gap exists: without the lock the claim on the *implementation* lived only in this daemon's * memory, so another machine's sweep could book the same ticket. Ticket, plan and lock live on * the data branch (#1582), so the agent retires them there once its work is published — nothing * else releases a lock since #1420 dropped the timer. The queue entry itself is NOT the agent's * to touch: the daemon checks it off at settle, once the run's ending reports the work landed. */ export declare function pinnedDrainJob(job: AutoPmJob, entry: string, assignment?: PlanAssignment): AutoPmJob; /** * A fan-out job pinned to one locked ticket (#1327). * * The stock prompt covers every ticket that has no plan or claim yet, and with a batch going out * that instruction is the same collision {@link pinnedDrainJob} exists for: every agent forks the * same checkout and would pick the same most-important ticket. The pin is *appended* to the stock * prompt rather than spliced into it, so the verdict rules the preset carries keep riding along * verbatim and a rewritten preset (the maintainer owns its wording) cannot silently lose the pin. * * The agent is also told which claim is its own: its ticket's `.lock.md` already exists with the * `CLAIMED:` line the daemon pushed (#1420), and finding anything else there means the * assignment is stale — another agent's claim, or work that landed meanwhile — so it stops. It * is told to delete the lock in the same data-branch commit as the plan (#1582), because nothing * else releases it: #1420 removed the staleness timer, so a forgotten lock stands until a human * clicks it away. */ export declare function pinnedPlanJob(job: AutoPmJob, assignment: PlanAssignment): AutoPmJob; /** * The default cycle: bring the tickets across from GitHub (#1208), triage the quick ones (#891), * then the significant-but-agreed ones (#892), and only then make more plans (#685). Planning is * the most expensive turn and the one whose output the earlier jobs consume, so it runs last. * * Importing leads because it is the only job that can add a ticket none of the others have seen * (#1334): a routine that triages and plans a set nothing ever refills eventually has nothing * left to do, and a new issue would wait for a human to press the button. It is safe to repeat -- * the preset resumes from `tickets/meta.json`'s `lastImportedAt` and reconciles, so a firing with * nothing changed since the last one is a no-op rather than a re-import. * * This rotation is what #891/#892 mean by "with a cron job regularly firing this preset". No * separate scheduler is involved and none is needed: the rotation already fires on every idle tick * where the queue is dry, which is exactly when the queue wants refilling. That is the opposite of * the maintenance sweep (#882), which is paced by a calendar because it looks at static history and * would otherwise never come due — hence its own {@link AUTO_PM_MAINTENANCE_JOB} outside the cycle. * * The gated triage sibling (#698) is deliberately not here: it ends in `<AWAIT>`, so firing it with * nobody at the keyboard would park an agent against a human who will never answer. * * Each triage prompt pins its own session name and aborts if that branch already exists, so a * rotation that comes round again while the previous triage is still in flight is a no-op rather * than a duplicate. The rotation still advances past it, which is the wanted behaviour: the next * idle tick tries the next job instead of retrying a job that is already running. */ export declare const AUTO_PM_JOBS: readonly AutoPmJob[]; /** * The job for a queue that is not empty (#855): work its first entry off. Outside the rotation * on purpose — the rotation is about what to *make* when there is nothing to do, and this is * the thing to do. */ export declare const AUTO_PM_DRAIN_JOB: AutoPmJob; /** * The periodic codebase-wide sweep (#882): fire the [Maintenance] preset (#881) so a repo that * adopted The Framework late gets its pre-existing history looked at. * * Outside the rotation, like {@link AUTO_PM_DRAIN_JOB} and for the same kind of reason: the * rotation is "what to make next" and cycles every idle tick, while this is paced by a calendar * and must not advance or be advanced by the cycle. It takes precedence over the rotation when * due, because the entries it queues are what the rotation would otherwise be inventing work * instead of. * * The prompt renders at module load with no session, so `tf.params.what` falls back to its * default of the entire codebase, which is exactly this job's scope. */ export declare const AUTO_PM_MAINTENANCE_JOB: AutoPmJob; /** * Every routine the sweep can fire, in the order a surface should list them (#1159). * * Derived from the three constants above rather than written out again, so the list the dashboard * shows and the jobs the daemon actually runs cannot drift. The order is the sweep's own precedence * (#855/#882 read the other way round): draining comes first because it is what happens whenever * there is queued work, the rotation is what happens when there is not, and the calendar-paced * maintenance sweep is the exception outside both. */ export declare const AUTO_PM_ROUTINES: readonly AutoPmJob[]; /** * What became of one attempt to land an agent's queue (#852). The two flags are separate on purpose: * a finished agent that wrote no queue is `settled` without being `promoted`, and must stop being * retried; a still-running one is neither, and is tried again next tick. */ export interface PromoteOutcome { /** Stop tracking this agent: it is finished, whether or not it left anything behind. */ settled: boolean; /** The checkout's queue actually changed. */ promoted: boolean; /** * Why the settled run's handoff skipped, when its record says so (#1583). Rides on this outcome * because the promotion already read the run's record, and the sweep needs exactly one fact * from it: a run that ended `no-commits` will never open the PR that lifts the `.lock.md` it * was started under, so the sweep releases that claim itself. */ handoffSkip?: AutoHandoffSkip; /** * The run finished cleanly but its epilogue has not reported yet (#1583): the `end` lands * before the handoff does its work, so a sweep can catch the gap between them. A claim-carrying * agent observed there is held pending a little longer rather than settled — settling would * drop the claim with the ending unread, and the release would be missed for good. */ handoffPending?: boolean; } /** A project the sweep considers. */ export interface AutoPmProject { /** Registry id, as `start` and the live-agent lookup take it. */ id: string; /** Absolute repo path, for reading its queue. */ path: string; } /** The readings and effects {@link startAutoPm} needs, injected so the loop is testable off disk. */ export interface AutoPmDeps { /** The projects to consider. */ projects(): Promise<readonly AutoPmProject[]>; /** The `autoPm` preference, re-read per tick so the toggle takes effect without a restart. */ enabled(): Promise<boolean>; /** * The routines the user has switched off, by {@link AutoPmJob.name} (#1209). Re-read per tick * for the same reason {@link AutoPmDeps.enabled} is, and an unreadable answer means none: * a preference that cannot be read must not silently switch the whole rotation off. */ optedOut?(): Promise<readonly string[]>; /** * The open entries of a project's agent queue, in file order. Empty = the queue has run dry, and * a rejection = it could not be read, which fails closed exactly as the old boolean did (#855). * The entries themselves rather than just emptiness, because a batch of drains is pinned one * entry each (#1204) and the assignment has to come from the read the decision was made on. */ queue(project: AutoPmProject): Promise<readonly string[]>; /** How many agents are live on a project. */ activeAgents(project: AutoPmProject): number; /** * How many agents the routine may keep going per project (#1204). Re-read per tick like * {@link AutoPmDeps.enabled}, so the setting takes effect without a restart. Unset or unreadable * falls back to {@link DEFAULT_AUTO_PM_CONCURRENCY} rather than to one: the absence of the * setting has never meant "less". */ concurrency?(): Promise<number | undefined>; /** * Where the account stands against its boundary for the work *this project* would start, or * `undefined` when there is no reading. * * Asked per project rather than once per sweep (#1619): the model is a project-resolvable * setting, and the model's own weekly window binds alongside the account's (#879) — so two * projects on two models can stand at two different places against the same reading. */ quota(project: AutoPmProject): Promise<QuotaBoundaryStatus | undefined>; /** The jobs to rotate through, in cycle order. Used only while the queue is empty. */ jobs: readonly AutoPmJob[]; /** The job for a queue with open entries (#855); {@link AUTO_PM_DRAIN_JOB} by default. */ drainJob?: AutoPmJob; /** * Whether a project is due its periodic codebase sweep (#882). Injected rather than computed * here because the schedule lives in a file in the project checkout, and this module is pure * policy. Omitted entirely (or throwing) means "not due", so a daemon that cannot read the * schedule keeps doing the rotation rather than sweeping on every tick. */ maintenanceDue?(project: AutoPmProject): Promise<boolean>; /** Stamp a project as swept, so the next sweep is an interval away. Paired with {@link AutoPmDeps.maintenanceDue}. */ recordMaintenance?(project: AutoPmProject): Promise<void>; /** The job fired when {@link AutoPmDeps.maintenanceDue} says yes; {@link AUTO_PM_MAINTENANCE_JOB} by default. */ maintenanceJob?: AutoPmJob; /** Start the PM agent. Resolves the agent's id, or undefined when the daemon refused. */ start(project: AutoPmProject, job: AutoPmJob): Promise<string | undefined>; /** * Release a {@link AutoPmJob.pinnedBranch} its closed PR left behind (#1293), called right * before such a job fires. Injected because the release reads `gh` and mutates git, and this * module is pure policy. Omitted (or throwing) means the branch stays and the job's own abort * guard decides, exactly as before the seam existed. */ releasePinned?(project: AutoPmProject, branch: string): Promise<unknown>; /** * Settle a finished agent's queue entry (#852/#1582): the daemon retires the entry it pinned to * the run once the run's ending says the work was published. Called before the sweep decides * anything, so an entry whose run just landed stops reading as open work to start over again. */ promote(project: AutoPmProject, agent: { agentId: string; entry?: string; }): Promise<PromoteOutcome>; /** * The tickets open for planning (#1327): no plan or `.lock.md` claim yet (#1420) — most * important first, as filenames inside `tickets/`. Asked only when the tick lands on a * {@link AutoPmJob.fansOut} job. Unreadable means none, and no seam at all means the stock * single driver: the fan-out is an addition, not a precondition, and a loop wired without it * behaves exactly as before #1327. */ planCandidates?(project: AutoPmProject): Promise<readonly string[]>; /** * Claim `assignments`' tickets before their agents start (#1327/#1420): one `.lock.md` sibling * per ticket reading `CLAIMED: <AGENT_ID>`, committed as one batch and pushed to the default * branch, so agents forked from any checkout — and cloud sessions, which is the point — find * the file and skip the ticket. Resolves the subset actually locked: a ticket lost to a race * locks fewer, and each missing lock costs one agent of the batch rather than the batch. * Failing (or absent) resolves nothing locked, and the sweep falls back to the stock single * agent — one unpinned agent is what ran before #1327 and needs no lock to be safe. */ lockPlans?(project: AutoPmProject, assignments: readonly PlanAssignment[]): Promise<readonly PlanAssignment[]>; /** * Claim the tickets a drain batch is about to implement (#1420), the same way * {@link AutoPmDeps.lockPlans} claims them for planning — one pushed `.lock.md` per ticket — * but skipping only on an existing lock: a `.plan.md` is the drain's input, not a competing * claim. Asked only for the entries that link back to a ticket; a self-contained TODO has * nothing on disk to lock and keeps the queue document as its coordination point. Resolves the * subset actually locked — an entry whose ticket was claimed elsewhere is dropped from the * batch, and the next tick reconsiders it. Absent, drains run exactly as before this seam: * the in-memory pin still guards this daemon's own fan-out. */ lockDrains?(project: AutoPmProject, assignments: readonly PlanAssignment[]): Promise<readonly PlanAssignment[]>; /** * Free a claim this loop minted whose agent settled with nothing to hand off (#1583): the * lock's normal release is the agent's own PR deleting it, and a run whose handoff skipped as * `no-commits` is never opening one — without this the queue livelocks on the dead claim until * a human clicks Release. Keyed off the run's recorded ending, never a timer (#1420). Only the * exact minted claim is freed — the callee leaves a lock naming anyone else alone. * * Resolves `true` when the claim is dealt with (freed, already gone, or someone else's), and * `false` when the release could not land — a transient `index.lock`, say — so the loop holds * the agent and tries again next sweep rather than losing the one shot. Absent (or throwing) * leaves the lock standing, exactly as before this seam. */ releaseLock?(project: AutoPmProject, claim: PlanAssignment): Promise<boolean>; /** Progress line. */ log(message: string): void; /** Override the tick interval. */ intervalMs?: number; /** Override the per-project cooldown. */ cooldownMs?: number; /** Clock, injectable for tests. */ now?: () => number; } /** What the last sweep decided about one project. */ export interface AutoPmOutcome { /** Registry id of the project considered. */ projectId: string; /** Its path, which is what the log line names and the panel shows. */ path: string; /** Whether an agent was started for it. */ started: boolean; /** The sentence: what was started, or the reason for standing down. */ message: string; } /** * What auto PM has done lately (#1161). * * Every decision was already logged (#855), but the log is the daemon's stdout and the toggle * lives in a browser, so from the dashboard a wedged sweep and a healthy idle one looked * identical — the same failure #855 fixed one layer down. */ export interface AutoPmReport { /** Whether the preference was on at the last sweep. `undefined` before the first one. */ enabled?: boolean; /** When the last sweep finished, epoch ms. `undefined` before the first one. */ sweptAt?: number; /** When the next sweep is due, epoch ms. */ nextSweepAt: number; /** One line per project the last sweep considered, in sweep order. */ outcomes: AutoPmOutcome[]; } /** * Where the dashboard reads {@link AutoPmReport} from. The daemon wires its live loop; a public * host (the relay) leaves it unset, and one that has not finished starting answers `undefined`. */ export type AutoPmReporter = () => AutoPmReport | undefined; /** A running sweep. */ export interface AutoPmLoop { /** * Run one sweep now, rather than waiting for the next tick. Called when the preference is * switched on (#1161) as well as from tests: the sweep re-reads it per tick, so without this * the box you just ticked does nothing at all for a whole interval. * * `onDemand` marks a sweep a person explicitly asked for (#1210's trigger button). The `autoPm` * preference is consent to spend quota *unasked*, and a click is asking — so an on-demand sweep * runs with the preference off, and the master switch is the only gate it skips: every other * reason to stand down (live agents, cooldowns, the quota boundary, unticked routines) still holds. * * `drainOnly` narrows the sweep to working the queue (#1204): the drain row's Run now means * "spin agents up on the queue", so a tick that would fall through to a rotation job (the queue * is empty) says so instead of borrowing the click for work nobody asked for. */ tick(opts?: { onDemand?: boolean; drainOnly?: boolean; }): Promise<void>; /** What the last sweep decided, for the dashboard to show (#1161). */ report(): AutoPmReport; stop(): void; } /** * Start the auto-PM sweep (#685): every {@link DEFAULT_AUTO_PM_INTERVAL_MS}, ask * {@link autoPmDecision} for each project and start an agent for the ones that say yes. * * Ticks never overlap — a sweep reads a live-agent map that its own `start` calls mutate, * so a second sweep running over the first would decide against a stale picture. * * Nothing here survives the daemon: per #519 a Ctrl+C that stops everything is the feature, * not a gap, so this loop is deliberately not restartable from outside the process. */ export declare function startAutoPm(deps: AutoPmDeps): AutoPmLoop; //# sourceMappingURL=auto-pm.d.ts.map