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
TypeScript
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