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
1 change: 1 addition & 0 deletions FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +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
- 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
2 changes: 1 addition & 1 deletion packages/the-framework/prompts/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Every word the framework says to a coding agent, authored as markdown: the built
## Flows

- The system prompt wraps the user's prompt in a working discipline: analyze it and gate on ambiguity or large scope, keep every read and write under the working directory, name the session and do all work on a branch of that name, offer alternatives wherever the best solution is unclear, and signal ready-for-merge only when nothing is left. Without that signal the work is never merged.
- The protocols define the agent's side of the conversation. One teaches how to park on a gate — a question that blocks the agent until the user answers: a choice, a multi-select, a document approval, handing the browser to a human at a login wall — and how to mark the answers that end the agent rather than resume it, so a rejection is not something it is asked to build on. Another teaches the non-blocking signals: show a document, name the session, ready-for-merge. Per-capability protocols adapt the rest: an agent with a real browser is told when to use it, and a hands-off agent is told gates can never be answered, so it assumes the recommended option and carries on.
- The protocols define the agent's side of the conversation. One teaches how to park on a gate — a question that blocks the agent until the user answers: a choice, a multi-select, a document approval, handing the browser to a human at a login wall — and how to mark the answers that end the agent rather than resume it, so a rejection is not something it is asked to build on. Another teaches the non-blocking signals: show a document, name the session, ready-for-merge, and describe the pull request the framework will open. Per-capability protocols adapt the rest: an agent with a real browser is told when to use it, and a hands-off agent is told gates can never be answered, so it assumes the recommended option and carries on.
- The presets are the one-click task prompts behind the dashboard's buttons: research, the quality reviews (readability, maintainability, security, UX), ticket triage and planning, and draining the queue.
- The format docs teach the repo conventions: tickets as dated proposal files with plan and claim siblings (`.plan.md`, `.lock.md`), and the priority-ordered queue file (`TODO_AGENTS.md`) of confirmed work.
- The before-mergeable prompt is the final quality turn: queue follow-up refactor and security passes when the changes warrant them, and fold what the agent learned into the project's knowledge base (`knowledge-base/*.md`).
Expand Down
7 changes: 7 additions & 0 deletions packages/the-framework/prompts/protocols/signal.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ You do not stop; re-emit it if you rename the session.
When you call setReadyForMerge() — you believe the work is complete and ready for human review — emit an empty `ready-for-merge` block. This flips the dashboard status from building to ready; it does not stop your turn.
```ready-for-merge
```

## 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:
```open-pr
<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.
20 changes: 20 additions & 0 deletions packages/the-framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { launchSharedBrowser, withBrowser, type SharedBrowser } from './browser.
import { connectCdp, startBrowserStream, type BrowserStream } from './browser-stream.js'
import { randomUUID } from 'node:crypto'
import { formatFrameworkEvent, mergeWithheldWhy } from './terminal.js'
import { defuseClosingKeywords } from './closing-keywords.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 @@ -547,6 +548,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 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 @@ -578,6 +581,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
// changes, and the handoff wants what it said last.
let prDescription: string | 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 @@ -592,6 +598,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 === 'session-name') {
sessionName = event.name
// The framework-owned checkout (#736) was branched as `tf-agent-<id>` before a
Expand Down Expand Up @@ -625,6 +632,7 @@ export function createAgentJournal(deps: {
onEvent,
sessionName: () => sessionName,
sawReadyForMerge: () => sawReadyForMerge,
prDescription: () => prDescription,
stoppedCleanly: () => stoppedCleanly,
announceBrowserPort: port => {
pendingBrowserPort = port
Expand Down Expand Up @@ -1027,12 +1035,24 @@ async function driveAgent(opts: AgentOptions, io: CliIO): Promise<number> {
const fixes = opts.ticket && isTicketPath(opts.ticket) && !opts.planAgent
? ticketIssueRef((await readDataFile(cwd, opts.ticket).catch(() => undefined)) ?? '')
: undefined
// The agent's own description of the work (#1567), when it wrote one: this is what an
// `open-pr` block is for — the agent describes the change and the framework opens the PR,
// so it has no reason to run `gh pr create` itself and lose the title convention, the
// ticket's issue reference, and the recorded number along the way.
//
// A plan agent's description is defused first: its PR lands the plan, not the work, so a
// 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 agent = {
id: opts.agentId ?? '',
branch,
...(sessionName ? { sessionName } : {}),
...(intent ? { intent } : {}),
...(fixes ? { fixes } : {}),
...(description ? { description } : {}),
}
const handedOff = await agentAutoHandoff(cwd, agent, armed)
const outcome =
Expand Down
23 changes: 23 additions & 0 deletions packages/the-framework/src/closing-keywords.SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Why a pull request that does not finish an issue must not carry a phrase GitHub reads as closing it, and how such a phrase is defused without changing what the sentence says or what the reader can click.

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

- A closing phrase is one of GitHub's keywords — close, fix, resolve, and their plural and past forms — followed directly by an issue reference, in this repository or another.
- Defusing puts the words "the ticket" between the keyword and the reference. GitHub only obeys the keyword when the reference follows it directly, so the phrase loses its authority while the sentence still reads as the agent wrote it.
- The reference itself is never touched, so it stays a live link and the issue still records that the pull request mentioned it.
- A reference already inside backticks is left alone: it is a code sample, GitHub does not act on it, and rewriting it would corrupt the sample.
- Text that has been through this once can go through it again unchanged, because the keyword is no longer followed by a reference.

## Rationales

- The phrase is defused rather than forbidden: an agent writing "…then close #1164" as the last step of a plan is describing its plan accurately, and the sentence is worth keeping — what is wrong is only that GitHub acts on it.
- A filler is chosen over wrapping the reference in backticks, which was the first form of this: backticks stopped the closing but also took away the link and the mention on the issue's timeline, so the ticket was no longer told that a pull request had discussed it. Breaking only the adjacency keeps everything a reader gains from the reference and removes just the part a machine acts on.

## Before modifying/creating SPEC.md files

You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
5 changes: 5 additions & 0 deletions packages/the-framework/src/closing-keywords.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Covers defusing GitHub's closing phrases: the exact sentence that closed #1164 keeps its words, the issue reference is never rewritten so it stays clickable and still cross-references, every keyword form and letter case is caught, the cross-repo reference is caught too, an issue mentioned without a keyword is untouched, a word merely ending in a keyword is not one, a reference inside backticks is a code sample rather than a command, and defusing twice changes nothing.

## Before modifying/creating SPEC.md files

You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
59 changes: 59 additions & 0 deletions packages/the-framework/src/closing-keywords.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'
import { defuseClosingKeywords } from './closing-keywords.js'

// #1567: a plan PR whose body ended "…then comment on and close #1164" closed #1164 on merge,
// and the next tickets sync then removed the ticket and its fresh plan. The words stay and the
// reference stays live; only GitHub's reading of them changes.
describe('defuseClosingKeywords', () => {
test('the phrase that closed #1164 keeps its words and loses its effect', () => {
const before = 'The plan proposes: expose `queued` on the single-ticket read — then comment on and close #1164.'
assert.equal(
defuseClosingKeywords(before),
'The plan proposes: expose `queued` on the single-ticket read — then comment on and close the ticket #1164.',
)
})

test('every keyword form GitHub accepts is defused, whatever the case', () => {
for (const keyword of ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved']) {
assert.equal(defuseClosingKeywords(`this ${keyword} #42`), `this ${keyword} the ticket #42`, keyword)
const upper = keyword.toUpperCase()
assert.equal(defuseClosingKeywords(`this ${upper} #42`), `this ${upper} the ticket #42`, keyword)
}
})

test('the reference itself is never rewritten, so it stays clickable and cross-references', () => {
// Rom's point on #1612: backticking the reference defused the phrase but also killed the
// link and the mention on the issue's own timeline. Only the adjacency may be broken.
assert.equal(defuseClosingKeywords('close #1164').includes('`'), false)
assert.match(defuseClosingKeywords('close #1164'), /(^|\s)#1164\b/)
})

test('the cross-repo form closes just as well, so it is defused too', () => {
assert.equal(
defuseClosingKeywords('fixes gemstack-land/the-framework#7'),
'fixes the ticket gemstack-land/the-framework#7',
)
})

test('an issue mentioned without a keyword is left alone — a reference is not a command', () => {
const text = 'See #1164 for the symptom, and the evidence table on #1334.'
assert.equal(defuseClosingKeywords(text), text)
})

test('a word that merely ends in a keyword is not one', () => {
const text = 'the enclose #42 case'
assert.equal(defuseClosingKeywords(text), text)
})

test('a reference already inside backticks is a code sample, not a command', () => {
const text = 'write `close #42` to close it'
assert.equal(defuseClosingKeywords(text), text)
})

test('running it twice changes nothing the second time', () => {
const once = defuseClosingKeywords('fixes #9 and closes #10')
assert.equal(defuseClosingKeywords(once), once)
assert.equal(once, 'fixes the ticket #9 and closes the ticket #10')
})
})
56 changes: 56 additions & 0 deletions packages/the-framework/src/closing-keywords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* GitHub's issue-closing grammar, and how to defuse it (#1567).
*
* A pull request whose body says `close #1164` closes that issue the moment the PR merges.
* That is the wanted behaviour when the PR completes the issue — the handoff puts `(fix #42)`
* on the title of an implementing agent's PR for exactly that reason (#1334). It is the wrong
* behaviour when the PR delivers something short of the work: a plan agent's PR lands a plan
* whose own text says the implementation is still to come, and a sentence like "…then close
* #1164" reads perfectly sensibly to the human reviewing it while quietly closing the ticket.
*
* That happened (#1560 closed #1164, and the next tickets sync then deleted the ticket and its
* fresh plan). The cure is not to forbid the phrase — the agent is describing its plan, and the
* sentence is true — but to break the adjacency GitHub's parser needs. The keyword only counts
* when the reference follows it directly, so two words in between end its authority while the
* sentence keeps saying what it said.
*/

/**
* The keywords GitHub accepts before an issue reference, per its "linking a pull request to an
* issue" documentation. Matched case-insensitively; every listed form (and its plural/past
* tense) is a live trigger.
*/
const CLOSING_KEYWORDS = ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved']

/**
* What goes between the keyword and the reference. Chosen to read as the sentence's own words
* rather than as an escape: "…then close the ticket #1164" is what the agent meant anyway.
*/
const FILLER = 'the ticket'

/**
* A closing keyword, whitespace, then an issue reference — `#123`, or the cross-repo
* `owner/repo#123` form, which closes just as well. A reference already inside backticks is
* left alone: GitHub does not act on one, and rewriting it would corrupt a code sample.
*/
const CLOSING_PHRASE = new RegExp(
String.raw`(^|[^\`\w])(${CLOSING_KEYWORDS.join('|')})(\s+)((?:[\w.-]+\/[\w.-]+)?#\d+)(?!\`)`,
'gi',
)

/**
* Rewrite every closing phrase in `text` so GitHub stops reading it as a command, leaving the
* issue reference itself untouched: `close #1164` becomes `close the ticket #1164`.
*
* The reference stays live — clickable, and still cross-referenced onto the issue's own timeline,
* so the ticket is told a pull request mentioned it. Only the closing authority is removed.
*
* Idempotent by construction: after the rewrite the keyword is followed by the filler rather than
* by a reference, so a second pass finds nothing to change.
*/
export function defuseClosingKeywords(text: string): string {
return text.replace(
CLOSING_PHRASE,
(_all, before: string, keyword: string, gap: string, ref: string) => `${before}${keyword}${gap}${FILLER} ${ref}`,
)
}
2 changes: 2 additions & 0 deletions packages/the-framework/src/dashboard/agent-handoff.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Hands a finished agent's work back to the user: measures what its branch holds,

- The user finds a settled agent's branch pushed and a draft pull request opened for it, without pressing anything.
- The user is told when an agent produced nothing; an empty agent is never published.
- The user reads a pull request written by the agent that did the work, instead of a restatement of the request the agent was given.
- The user pushes, opens the PR, or merges by hand from the handoff panel — the merge button lands the draft an automatic merge withheld.
- The user arms an automatic merge in configuration, and it still waits for the agent's own ready signal.
- The user sees a cloud agent's pull request even though its branch never existed on this machine.
Expand All @@ -12,6 +13,7 @@ Hands a finished agent's work back to the user: measures what its branch holds,

- The read is addressed by branch, not by checkout: an agent reads the same whether or not its checkout still exists. A branch gone locally still reports its pull request — a hands-off cloud agent only ever pushed to the remote.
- An agent that produced nothing — no commits, or changes only to the framework's own records — is reported as empty and never published.
- The pull request describes the work in the agent's own words, when the agent wrote a description for it; otherwise it repeats what the user asked for, which is all the framework knows by itself.
- Push and a draft PR are armed by default; drafts keep the automatic path out of reviewers' inboxes. Whatever the agent left uncommitted is swept into a commit first, guarded so only the agent's own checkout and branch are ever committed.
- The PR number is recorded on the agent the moment one is opened for it, so every surface reads the same integer instead of re-deriving it. Its *state* is still read live, because that changes without the agent doing anything.
- A pull request opened after the agent's process is gone is recorded too, by patching the agent's archived record: it is the same fact, and a surface should not have to know which of the two paths produced it.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
The tests cover the whole handoff story: reading a branch's work (empty, bookkeeping-only, gone, unpushed, and no-remote cases, against fakes and real repos), push and PR-opening with git's own reason on failure, the push-free draft PR for a remote-only branch with gh's refusal reported rather than thrown, the armed push/draft-PR/merge combinations including never opening a second PR, the recorded branch and PR winning over re-derivation, merge authorization, and the human Merge action with its refusals.
The tests cover the whole handoff story: reading a branch's work (empty, bookkeeping-only, gone, unpushed, and no-remote cases, against fakes and real repos), push and PR-opening with git's own reason on failure, the push-free draft PR for a remote-only branch with gh's refusal reported rather than thrown, the armed push/draft-PR/merge combinations including never opening a second PR, the recorded branch and PR winning over re-derivation, the PR body carrying the agent's own description of the work and falling back to what was asked for when the agent wrote none, merge authorization, and the human Merge action with its refusals.

## Before modifying/creating SPEC.md files

Expand Down
Loading
Loading