Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ happens while nobody is at the keyboard.
- Commit what the agent left uncommitted
- Push the branch (on by default)
- Open a PR (on by default)
- The PR is described by the agent that did the work, when it wrote a description for it
- The PR is named and described by the agent that did the work, when it wrote them — never titled with the prompt it was given
- Auto-merge — armed by config, authorized by the agent's ready signal
- Empty agents publish nothing
- Handoff panel: push / open PR / merge, as buttons
Expand Down Expand Up @@ -133,6 +133,7 @@ happens while nobody is at the keyboard.

- Usage panel: quota consumed, pace, projection
- Unattended work stands down past the pro-rated share of the week — quota gates *starting* work, never an agent already running
- The chosen model's own weekly allowance gates unattended work too — a spent model week holds the work back while the account's week still has room
- Work you asked for is never starved
- Spend-offset slider — the one budget control for autonomous work

Expand Down
6 changes: 4 additions & 2 deletions packages/the-framework/prompts/protocols/signal.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ When you call setReadyForMerge() — you believe the work is complete and ready
```

## Opening a pull request
Whenever you emit `ready-for-merge`, emit an `open-pr` block too, describing the work. The Framework opens the pull request for you and this block is its body — you do not need to run `gh pr create` yourself:
Whenever you emit `ready-for-merge`, emit an `open-pr` block too, naming and describing the work. The Framework opens the pull request for you — you do not need to run `gh pr create` yourself. Write it like a commit message: the first line is the title, the rest is the body.
```open-pr
<one line naming what the change does, under 100 characters>

<what changed, and why — markdown, as long as it needs to be>
```
Without it the pull request can only repeat the prompt you were given, which does not say what the work turned out to be. The Framework supplies everything else: the title from your session name, the ticket's issue reference where there is one, and recording the number so every surface shows the same pull request. You do not stop, and you can re-emit it as the work changes — the last one is used. Opening the pull request yourself instead still works; you then own all of the above.
Without it the pull request has no name for your work and can only repeat the prompt you were given, which does not say what the work turned out to be. The Framework supplies the rest: the ticket's issue reference where there is one, and recording the number so every surface shows the same pull request. You do not stop, and you can re-emit it as the work changes — the last one is used. Opening the pull request yourself instead still works; you then own all of the above.
1 change: 1 addition & 0 deletions packages/the-framework/src/auto-pm.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Auto PM spends leftover subscription quota on the product's own roadmap: while t
## Rationales

- An unreadable quota fails closed — the opposite of the per-agent guard: quietly burning quota on work nobody asked for is worse than skipping a pass.
- Where the account stands is asked per project rather than once per pass, because the model a project's work would run on is a project setting and each model's own weekly allowance binds alongside the account's. Two projects on two models can therefore stand at two different places against the same reading.
- "Run now" skips only the master switch: the click is the consent the preference exists to record; every other stand-down holds.
- A switched-off draining routine falls through to the rotation rather than standing the pass down, because a stand-down would make every inventing routine unreachable whenever the queue holds anything — and the queue is auto-populated, so it usually does.
- The ticketless hand-off window is accepted rather than closed: closing it would take a durable per-entry claim — a second claim shape beside the pushed ticket lock that already covers the queue's normal case — for a race whose cost is a duplicated attempt, never lost work.
Expand Down
17 changes: 12 additions & 5 deletions packages/the-framework/src/auto-pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,15 @@ export interface AutoPmDeps {
* setting has never meant "less".
*/
concurrency?(): Promise<number | undefined>
/** Where the account stands against its boundary, or `undefined` when there is no reading. */
quota(): Promise<QuotaBoundaryStatus | 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. */
Expand Down Expand Up @@ -684,9 +691,6 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop {
// the cycle stays a cycle: with two of four off, the remaining two alternate instead of
// every other tick landing on a job that cannot run.
const rotation = deps.jobs.filter(job => !optedOut.has(job.name))
// One reading for the whole sweep: it is an account-wide meter, and re-reading it per
// project would spend a rate-limited call to learn the same number.
const quota = await deps.quota().catch(() => undefined)
// How many agents each project may keep going (#1204). Read beside the opt-outs and for the
// same reason: it is the same preference file, re-read so the setting takes effect
// mid-schedule. Floored at one, since zero agents is the master switch's job.
Expand Down Expand Up @@ -747,6 +751,9 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop {
}
}
const entries = await deps.queue(project).catch(() => undefined)
// Per project, because the model the work would run on is (#1619). It costs no reading:
// the meter is polled elsewhere and this only measures the last one against the boundary.
const quota = await deps.quota(project).catch(() => undefined)
const activeAgents = deps.activeAgents(project)
const since = lastStart.get(project.id)
const decision = autoPmDecision({
Expand Down
22 changes: 14 additions & 8 deletions packages/the-framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { connectCdp, startBrowserStream, type BrowserStream } from './browser-st
import { randomUUID } from 'node:crypto'
import { formatFrameworkEvent, mergeWithheldWhy } from './terminal.js'
import { defuseClosingKeywords } from './closing-keywords.js'
import type { ParsedPullRequest } from './turn-gate.js'
import { CLAUDE_CODE_SESSION_LINK } from './session-link.js'
import { type AutoHandoffSkip, type ChoicePick, type ChoiceRequest, type FrameworkEvent, type MergeWithheldReason, type OnBeforeMergeableSkip } from './events.js'
import { agentAutoHandoff, withheldMerge } from './dashboard/agent-handoff.js'
Expand Down Expand Up @@ -548,8 +549,8 @@ export interface AgentJournal {
sessionName: () => string | undefined
/** The agent signalled setReadyForMerge() this agent (#326). */
sawReadyForMerge: () => boolean
/** The pull-request description the agent wrote via an `open-pr` block (#1567), if any. */
prDescription: () => string | undefined
/** The pull request the agent asked for via an `open-pr` block (#1567/#1618), if any. */
pullRequest: () => ParsedPullRequest | undefined
/** The agent stopped cleanly (user interrupt / budget cap #322) rather than failed. */
stoppedCleanly: () => boolean
/** Hold the browser preview's port until the session opens (#829/#813). */
Expand Down Expand Up @@ -581,9 +582,9 @@ export function createAgentJournal(deps: {
let stoppedCleanly = false
let sawReadyForMerge = false
let sessionName: string | undefined
// The agent's own pull-request description (#1567), latest wins: it may revise it as the work
// The pull request the agent asked for (#1567), latest wins: it may revise it as the work
// changes, and the handoff wants what it said last.
let prDescription: string | undefined
let pullRequest: ParsedPullRequest | undefined
// The browser preview's port, announced on the first `session` event rather than when the
// bridge opens (#829): the dashboard renders only the tail from the last `session` event, so
// anything emitted ahead of it is dropped from the agent's view.
Expand All @@ -598,7 +599,7 @@ export function createAgentJournal(deps: {

const onEvent = (event: FrameworkEvent) => {
if (event.kind === 'ready-for-merge') sawReadyForMerge = true
if (event.kind === 'pull-request-description') prDescription = event.description
if (event.kind === 'open-pr') pullRequest = { ...(event.title ? { title: event.title } : {}), ...(event.description ? { description: event.description } : {}) }
if (event.kind === 'session-name') {
sessionName = event.name
// The framework-owned checkout (#736) was branched as `tf-agent-<id>` before a
Expand Down Expand Up @@ -632,7 +633,7 @@ export function createAgentJournal(deps: {
onEvent,
sessionName: () => sessionName,
sawReadyForMerge: () => sawReadyForMerge,
prDescription: () => prDescription,
pullRequest: () => pullRequest,
stoppedCleanly: () => stoppedCleanly,
announceBrowserPort: port => {
pendingBrowserPort = port
Expand Down Expand Up @@ -1044,14 +1045,19 @@ async function driveAgent(opts: AgentOptions, io: CliIO): Promise<number> {
// closing phrase in it would close the ticket's issue on merge — which is exactly what
// happened on #1560. The same reasoning already keeps `(fix #N)` off a plan agent's title
// just above; the description is the other half of the same rule.
const written = journal.prDescription()
const description = written && opts.planAgent ? defuseClosingKeywords(written) : written
const written = journal.pullRequest()
// Both halves are defused, not just the body: since #1618 the title is the agent's prose too,
// and a closing phrase there would ride the squash-merge subject straight into the issue.
const defuse = (text: string | undefined) => (text && opts.planAgent ? defuseClosingKeywords(text) : text)
const prTitle = defuse(written?.title)
const description = defuse(written?.description)
const agent = {
id: opts.agentId ?? '',
branch,
...(sessionName ? { sessionName } : {}),
...(intent ? { intent } : {}),
...(fixes ? { fixes } : {}),
...(prTitle ? { prTitle } : {}),
...(description ? { description } : {}),
}
const handedOff = await agentAutoHandoff(cwd, agent, armed)
Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/closing-keywords.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Why a pull request that does not finish an issue must not carry a phrase GitHub

## User Stories

- The user merges a plan's pull request and the ticket it discusses stays open, even though the plan's own text says the work will close it.
- The user merges a plan's pull request and the ticket it discusses stays open, even though the plan's own text — its title as much as its body — says the work will close it.
- The user follows the issue reference in that sentence, and finds it still links to the issue — and the issue still shows that the pull request mentioned it.

## Flows
Expand Down
2 changes: 2 additions & 0 deletions packages/the-framework/src/daemon-services.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Everything the daemon runs in the background beside serving the dashboard: Disco
- The user toggles a background service on the dashboard and it takes effect without restarting the daemon.
- The user pastes a Discord webhook into the dashboard and notifications start immediately.
- The user walks away and idle quota is spent on the roadmap: unattended agents drain the confirmed queue, CI-green pull requests are merged, and red ones get a fix agent.
- The user finds unattended work held back, and the reason said, when the model that work would run on has spent its own weekly allowance — rather than a stack of runs that died at their first request.
- The user reads on this machine what other machines and cloud sessions pushed, without waiting.
- The user sees a project flagged when its shared data cannot reach origin, and the flag clears with the first sync that converges.

Expand All @@ -15,6 +16,7 @@ Everything the daemon runs in the background beside serving the dashboard: Disco
- An agent the daemon starts resolves its options from the same two tiers a dashboard start uses — the user's settings, then the repo's committed file — so an agent nobody asked for and one someone clicked differ only in who asked.
- Auto PM spends idle quota on the roadmap: it fans out up to the configured number of unattended agents, each pinned to one queue entry. An entry is retired on the data branch (the dedicated branch the framework's shared records live on) once its agent's ending reports the work published — and it is the daemon, never the agent, that writes queue check-offs and ticket locks.
- The CI watch merges a watched PR once its checks pass, and puts a fix agent on one whose checks fail.
- Every job that starts work on its own measures the account against the model that start would use, resolved from the same two tiers the start itself resolves. A model whose own weekly allowance is spent therefore stands the work down, however much of the account's week is left. The fix half says its stand-down once per failing head commit rather than on every turn of the clock.
- An hourly sweep deletes the dead refs Claude-web hand-offs leave on origin, once they are old enough and provably hold no work.
- Settled web runs are matched to the `claude/*` branch that grew out of their hand-off, and the branch and its PR are adopted onto the run's record — with the armed draft PR opened when the session never opened one.
- The Discord notification watchers are rebuilt when the webhook changes, so a value pasted into the dashboard works immediately.
Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/daemon-services.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Covers the concurrent-agents setting end to end over a real data branch: the number stored on disk is the number of unattended agents the auto-PM sweep fans out, one pinned queue entry each with its claim committed on the branch, whether the sweep fires on schedule or from the dashboard's run-now click with auto-run off. Also covers one project's data-sync turn: a repository whose data branch cannot reach a remote is recorded as a data-sync error, and the error is cleared by the first sync that converges.
Covers the concurrent-agents setting end to end over a real data branch: the number stored on disk is the number of unattended agents the auto-PM sweep fans out, one pinned queue entry each with its claim committed on the branch, whether the sweep fires on schedule or from the dashboard's run-now click with auto-run off. Also covers the quota gate every self-starting job passes through: with the account's own week under its line but the chosen model's week spent, the sweep starts nothing and says which window held it back — while the same reading starts work as usual for a project whose model still has its own allowance. Also covers one project's data-sync turn: a repository whose data branch cannot reach a remote is recorded as a data-sync error, and the error is cleared by the first sync that converges.

## Before modifying/creating SPEC.md files

Expand Down
Loading
Loading