From 3412eab8f647beee8176a137b58eac1c64ce4c96 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 28 Aug 2026 21:32:15 +0300 Subject: [PATCH 1/2] The data branch is agents-data, checked out under .branches/ (#1736) --- .the-framework/.gitignore | 2 +- .the-framework/LAYOUT | 2 +- FEATURES-SPEC.md | 2 +- SPEC.md | 2 +- packages/framework/prompts/README.md | 2 +- packages/framework/prompts/SPEC.md | 2 +- .../framework/prompts/data_branch_protocol.SPEC.md | 14 +++++++------- packages/framework/prompts/data_branch_protocol.md | 6 +++--- packages/framework/src/SPEC.md | 2 +- .../framework/src/archived-agent-patch.test.ts | 2 +- packages/framework/src/daemon-services.test.ts | 2 +- .../framework/src/dashboard-rpc/control.test.ts | 4 ++-- .../framework/src/dashboard/open-questions.test.ts | 2 +- packages/framework/src/dashboard/open-questions.ts | 2 +- packages/framework/src/data-branch.SPEC.md | 10 +++++----- packages/framework/src/data-branch.ts | 14 +++++++++----- packages/framework/src/framework-dir.SPEC.md | 4 +--- packages/framework/src/framework-dir.ts | 11 ++++++----- packages/framework/src/framework-gitignore.ts | 4 ++-- packages/framework/src/install.test.ts | 8 +++----- packages/framework/src/routine-locks.test.ts | 9 +++++---- packages/framework/src/routine-locks.ts | 4 ++-- .../framework/src/store/agent-checkout.test.ts | 4 ++-- packages/framework/src/store/agent-store.test.ts | 7 ++++--- packages/framework/src/ticket-locks.test.ts | 3 ++- 25 files changed, 64 insertions(+), 60 deletions(-) diff --git a/.the-framework/.gitignore b/.the-framework/.gitignore index 797612f7f..31990c503 100644 --- a/.the-framework/.gitignore +++ b/.the-framework/.gitignore @@ -1,4 +1,4 @@ -# The Framework: agent state is transient; the lasting records live on the tf-data branch. +# The Framework: agent state is transient; the lasting records live on the agents-data branch. * !.gitignore !LAYOUT diff --git a/.the-framework/LAYOUT b/.the-framework/LAYOUT index ade82828e..b15f1b77c 100644 --- a/.the-framework/LAYOUT +++ b/.the-framework/LAYOUT @@ -1,5 +1,5 @@ framework-dir: .the-framework -data-branch: tf-data +data-branch: agents-data archive-dir: agents events-file: events.jsonl meta-file: agent.json diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index a568a592a..50947687e 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -122,7 +122,7 @@ happens while nobody is at the keyboard. - Empty agents publish nothing - Handoff panel: push / open PR / merge, as buttons - A withheld merge is reported with its reason -- Agent history archived on the `tf-data` branch under per-user directories — pushed the moment a session settles +- Agent history archived on the `agents-data` branch under per-user directories — pushed the moment a session settles - Post-merge quality follow-ups queued (maintainability / security) - Knowledge folded back into `DECISIONS.md` / `FACTS.md` / `INSIGHTS.md` at merge diff --git a/SPEC.md b/SPEC.md index 609ee4fb8..bc31db911 100644 --- a/SPEC.md +++ b/SPEC.md @@ -22,7 +22,7 @@ Every user-facing feature is enumerated in `FEATURES-SPEC.md`. - **The agent is the unit of work** - one task, in its own git worktree on its own branch, streaming everything it does as events; finished work is pushed and leaves as a pull request. - **Black-box driving** - the framework prompts the wrapped coding-agent CLI, lets the CLI's own loop run a full turn, and learns everything from the turn's final message: the session name the agent invented, the questions it stops to ask, and the ready-for-merge signal. - **Autonomy bounded by the account's own quota** - unattended work runs only while the account is under its pro-rated quota boundary; work a human asks for is never blocked, and a running agent is never interrupted over quota. -- **Framework data on its own branch** - everything The Framework itself writes (tickets, the agent queue, agent archives) lives on the `tf-data` branch, so the default branch stays 100% code. +- **Framework data on its own branch** - everything The Framework itself writes (tickets, the agent queue, agent archives) lives on the `agents-data` branch, so the default branch stays 100% code. ## Business logic diff --git a/packages/framework/prompts/README.md b/packages/framework/prompts/README.md index 60d1208d0..0b8081224 100644 --- a/packages/framework/prompts/README.md +++ b/packages/framework/prompts/README.md @@ -8,7 +8,7 @@ is written in TypeScript any more, so prompting can change without touching the | `system_prompt.md` | The built-in system prompt (#326). Rom's doc. Its `# User prompt` slot is where the user's own text is rendered, for a build and a prompt session alike (#1691). | | `ticketing_format.md` | The ticket file format: how a ticket, its plan and its lock are written under `tickets/`. Travels in every agent's context. | | `todo_format.md` | The agent-queue format: how `TODO_AGENTS.md` is banded by priority. Travels in every agent's context. | -| `data_branch_protocol.md` | The data-branch protocol: tickets, the queue and the session archives live on `tf-data`, read and written there directly, never on a code branch. Travels in every agent's context. | +| `data_branch_protocol.md` | The data-branch protocol: tickets, the queue and the session archives live on `agents-data`, read and written there directly, never on a code branch. Travels in every agent's context. | | `branch_yourself.md` | The "Branch management" section for an agent that runs outside a checkout The Framework created (#1725): the command is not there, so it branches with git itself. Agents in their own checkout get the `skill-branches` package's `SKILL.md` instead. | | `triage_scope.md` | The queue-only rule appended to both triage presets: a triage writes `TODO_AGENTS.md`, never a ticket's code (#1641). | | `on_before_mergeable_prompt.md` | The optional extra turn an agent gets when it signals ready for merge: queue quality follow-ups, fold what it learned into the knowledge base. | diff --git a/packages/framework/prompts/SPEC.md b/packages/framework/prompts/SPEC.md index 8c175e439..cca56c242 100644 --- a/packages/framework/prompts/SPEC.md +++ b/packages/framework/prompts/SPEC.md @@ -43,7 +43,7 @@ See `## User story`. - **The system prompt** — The Framework's built-in standing instructions for every agent, and the slot the user's own prompt is rendered into. The user's own additions live in their repo instead, not here. - **The file formats** — the ticketing format (how a ticket, its lock and its plan are written) and the agent-queue format (how `TODO_AGENTS.md` is banded by priority). They travel with the agent's context, so the agent has already read the format rather than having to find the file that describes it. -- **The data-branch protocol** — that tickets, the agent queue and the agent archives live on `tf-data`, are read off that branch, and are written straight to it instead of riding a pull request. +- **The data-branch protocol** — that tickets, the agent queue and the agent archives live on `agents-data`, are read off that branch, and are written straight to it instead of riding a pull request. - **The triage scope rule** — the one-paragraph rule appended to both triage presets, that a triage only queues work and never implements it. - **The protocols** — how an agent signals to The Framework, and what this particular agent can do. - **The presets** — one file per preset: the launcher's buttons and the daemon's routine prompts. diff --git a/packages/framework/prompts/data_branch_protocol.SPEC.md b/packages/framework/prompts/data_branch_protocol.SPEC.md index 1f799a133..90c506d8c 100644 --- a/packages/framework/prompts/data_branch_protocol.SPEC.md +++ b/packages/framework/prompts/data_branch_protocol.SPEC.md @@ -1,10 +1,10 @@ -Tells the agent where The Framework's own data lives and how to touch it: tickets, the agent queue and the agent archives sit on the data branch `tf-data`, are read off that branch without ever checking it out, and are written as commits pushed straight to it rather than riding the agent's pull request. +Tells the agent where The Framework's own data lives and how to touch it: tickets, the agent queue and the agent archives sit on the data branch `agents-data`, are read off that branch without ever checking it out, and are written as commits pushed straight to it rather than riding the agent's pull request. ## Business logic — TL;DR -- **The data is not in the checkout** - `tickets/**.md` (plans and locks included), `TODO_AGENTS.md` and the agent archives exist only on `tf-data`, so the agent reads them off the branch after fetching, falling back to the remote copy when there is no local branch. -- **Writes go straight to the data branch** - only when the agent's instructions say to edit tickets or the queue, and then as a commit on `tf-data` pushed immediately, rebasing and retrying if the push is rejected. -- **Data never mixes with code** - the agent never switches its checkout to `tf-data` and never places these files on its own agent branch, so a data change is published on its own instead of arriving inside the pull request. +- **The data is not in the checkout** - `tickets/**.md` (plans and locks included), `TODO_AGENTS.md` and the agent archives exist only on `agents-data`, so the agent reads them off the branch after fetching, falling back to the remote copy when there is no local branch. +- **Writes go straight to the data branch** - only when the agent's instructions say to edit tickets or the queue, and then as a commit on `agents-data` pushed immediately, rebasing and retrying if the push is rejected. +- **Data never mixes with code** - the agent never switches its checkout to `agents-data` and never places these files on its own agent branch, so a data change is published on its own instead of arriving inside the pull request. ## Business logic @@ -16,7 +16,7 @@ The user wants the project's default branch to stay 100% code. Everything The Fr #### Business logic -The agent is told that this data lives on the dedicated branch `tf-data` and never on code branches, so its own checkout does not contain those files at all. To read one it fetches first and then reads the file directly out of the branch, using the remote copy when the local branch does not exist. +The agent is told that this data lives on the dedicated branch `agents-data` and never on code branches, so its own checkout does not contain those files at all. To read one it fetches first and then reads the file directly out of the branch, using the remote copy when the local branch does not exist. ### Writes go straight to the data branch @@ -26,7 +26,7 @@ A triage or planning agent's whole output is a change to tickets or to the agent #### Business logic -The agent writes these files only when its instructions tell it to edit tickets or the queue. Such a write is a commit made on `tf-data` and pushed immediately; a rejected push is rebased and retried rather than abandoned. +The agent writes these files only when its instructions tell it to edit tickets or the queue. Such a write is a commit made on `agents-data` and pushed immediately; a rejected push is rebased and retried rather than abandoned. #### Rationale @@ -40,7 +40,7 @@ The user reviews an agent's pull request as a diff of code. Bookkeeping files ap #### Business logic -Two prohibitions carry this: the agent never switches its own checkout over to `tf-data`, and never puts these files on its agent branch. A data change is pushed directly and does not ride the pull request. +Two prohibitions carry this: the agent never switches its own checkout over to `agents-data`, and never puts these files on its agent branch. A data change is pushed directly and does not ride the pull request. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/prompts/data_branch_protocol.md b/packages/framework/prompts/data_branch_protocol.md index 1a98fba1c..8b4834300 100644 --- a/packages/framework/prompts/data_branch_protocol.md +++ b/packages/framework/prompts/data_branch_protocol.md @@ -1,7 +1,7 @@ # The data branch -The framework's own data — `tickets/**.md` (plans and locks included), `TODO_AGENTS.md`, the session archives — lives on the dedicated branch `tf-data`, never on code branches. Your checkout does not contain these files. +The framework's own data — `tickets/**.md` (plans and locks included), `TODO_AGENTS.md`, the session archives — lives on the dedicated branch `agents-data`, never on code branches. Your checkout does not contain these files. -Read them off the branch (fetch first): `git show tf-data:` — `origin/tf-data:` when the local branch doesn't exist. +Read them off the branch (fetch first): `git show agents-data:` — `origin/agents-data:` when the local branch doesn't exist. -Write them, only when the instructions say to edit tickets or the queue, as a commit on `tf-data` pushed immediately — rebase and retry on a rejected push. Never switch your checkout to the branch, and never put these files on your session branch: a data change is pushed directly, it does not ride your PR. +Write them, only when the instructions say to edit tickets or the queue, as a commit on `agents-data` pushed immediately — rebase and retry on a rejected push. Never switch your checkout to the branch, and never put these files on your session branch: a data change is pushed directly, it does not ride your PR. diff --git a/packages/framework/src/SPEC.md b/packages/framework/src/SPEC.md index c565b589e..96d326ea3 100644 --- a/packages/framework/src/SPEC.md +++ b/packages/framework/src/SPEC.md @@ -6,7 +6,7 @@ Everything of The Framework that runs in Node: the CLI entry, the daemon, the ag - **The agent lifecycle** - an agent gets a worktree and a branch, is framed with the built-in system prompt, works turn by turn through the driver, parks on gates when it needs the user, works the agent queue when its main task settles, and publishes itself per its handoff level when done. - **The driver seam** - the wrapped coding-agent CLI is a black box (the `agent-driver` package; `driver/` holds only the product's own cloud-session implementation): prompt in, full turn out, everything learned by parsing the turn's final message. Which CLI (Claude Code, Codex) and where it runs (this device, a GitHub Actions runner, a Claude Code cloud session) are two separate axes. - **Files are the seam** - an agent appends events to `.the-framework/events.jsonl`; steering flows back through `.the-framework/control.jsonl`; the agent's status lives in `agent.json`. The daemon and every surface are projections of these files (`store/`) — there is no process-to-process IPC. -- **The data branch** - tickets, the agent queue, and agent archives are committed to the `tf-data` branch through one serialized sync → apply → commit → push funnel, giving every machine and cloud session the same view while the default branch stays 100% code. +- **The data branch** - tickets, the agent queue, and agent archives are committed to the `agents-data` branch through one serialized sync → apply → commit → push funnel, giving every machine and cloud session the same view while the default branch stays 100% code. - **Autonomy, bounded** - on one shared background clock the daemon runs Auto PM (drain the agent queue, triage, plan — at most one firing per routine at a time, guarded by a routine lock on the data branch), the CI watch (merge on green, fix on red), and the sweeps (reclaim pushed checkouts, adopt cloud work, expire dead refs) — each start gated by the quota boundary. - **The dashboard's server side** - `dashboard/` (in this directory) serves the built browser app and implements its reads, live event stream, actions, and daemon-to-daemon relay; `dashboard-rpc/` is the RPC surface itself. diff --git a/packages/framework/src/archived-agent-patch.test.ts b/packages/framework/src/archived-agent-patch.test.ts index 4805acde8..e88c771fe 100644 --- a/packages/framework/src/archived-agent-patch.test.ts +++ b/packages/framework/src/archived-agent-patch.test.ts @@ -50,7 +50,7 @@ test('an archive patch lands as a commit on the data branch, pushed, leaving the // Committed, not merely written: a dirty data checkout is what the next sync hard-resets. assert.equal((await git('git', ['status', '--porcelain'], { cwd: dir })).stdout.trim(), '') assert.equal((await git('git', ['log', '-1', '--format=%s'], { cwd: dir })).stdout.trim(), '[The Framework] adopt r1') - assert.equal((await git('git', ['rev-list', '--count', 'origin/tf-data..tf-data'], { cwd: dir })).stdout.trim(), '0', 'and pushed') + assert.equal((await git('git', ['rev-list', '--count', 'origin/agents-data..agents-data'], { cwd: dir })).stdout.trim(), '0', 'and pushed') // The sync the daemon runs a minute later keeps it. await withDataBranch(project, '[The Framework] data sync', async () => {}) const after = JSON.parse(await readFile(join(dir, 'agents', 'u', 'r1.json'), 'utf8')) as { branch: string } diff --git a/packages/framework/src/daemon-services.test.ts b/packages/framework/src/daemon-services.test.ts index 34a668915..d3c298ff2 100644 --- a/packages/framework/src/daemon-services.test.ts +++ b/packages/framework/src/daemon-services.test.ts @@ -206,7 +206,7 @@ test('a drained entry is checked off on the data branch once its run reports the const deadline = Date.now() + 5000 let subjects = '' while (Date.now() < deadline) { - subjects = await git('git', ['log', '--format=%s', 'tf-data'], { cwd: projectDir }).then(r => r.stdout, () => '') + subjects = await git('git', ['log', '--format=%s', 'agents-data'], { cwd: projectDir }).then(r => r.stdout, () => '') if (subjects.includes('check off a drained entry')) break await new Promise(resolve => setTimeout(resolve, 25)) } diff --git a/packages/framework/src/dashboard-rpc/control.test.ts b/packages/framework/src/dashboard-rpc/control.test.ts index f037d2430..94c5eec6a 100644 --- a/packages/framework/src/dashboard-rpc/control.test.ts +++ b/packages/framework/src/dashboard-rpc/control.test.ts @@ -118,8 +118,8 @@ test('sendReleaseTicketLock deletes the lock and commits the release (#1420/#158 assert.deepEqual(result, { ok: true }) // The release is a data-branch commit: the lock is gone from the branch, not just a checkout. const git = nodeGitRunner() - await assert.rejects(git(['show', 'tf-data:tickets/2026-07-20_thing.lock.md'], cwd)) - assert.equal(await git(['show', 'tf-data:tickets/2026-07-20_thing.md'], cwd), '# Thing\n') + await assert.rejects(git(['show', 'agents-data:tickets/2026-07-20_thing.lock.md'], cwd)) + assert.equal(await git(['show', 'agents-data:tickets/2026-07-20_thing.md'], cwd), '# Thing\n') } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/packages/framework/src/dashboard/open-questions.test.ts b/packages/framework/src/dashboard/open-questions.test.ts index 79ad7d4b6..4acf523c6 100644 --- a/packages/framework/src/dashboard/open-questions.test.ts +++ b/packages/framework/src/dashboard/open-questions.test.ts @@ -157,7 +157,7 @@ test('a web agent\'s question arrives from the bridge and is answerable by label }) test('two checkouts of one repository yield one card for a bridged question, not two (#1554)', async () => { - // They share a tf-data archive, so the same web run is in both projects' agent lists. + // They share a agents-data archive, so the same web run is in both projects' agent lists. const agents = async () => [{ status: 'done' as const, id: 'run-web', startedAt: '', updatedAt: '', target: 'web' as const, sessionId: 'session_01Web' }] const questions = await buildOpenQuestions([PROJECTS[0]!, { id: 'p2', path: '/two', name: 'two', activated: true }], { liveAgents: async () => [], diff --git a/packages/framework/src/dashboard/open-questions.ts b/packages/framework/src/dashboard/open-questions.ts index aecac1b53..f49f935ed 100644 --- a/packages/framework/src/dashboard/open-questions.ts +++ b/packages/framework/src/dashboard/open-questions.ts @@ -96,7 +96,7 @@ export async function buildOpenQuestions( const events = deps.events ?? readEventLog const agents = deps.agents ?? readAllAgents // One card per bridged question, whichever project claims it first: two checkouts of the same - // repository share their `tf-data` archive, so the web run behind a question shows up under each. + // repository share their `agents-data` archive, so the web run behind a question shows up under each. const bridged = (deps.bridged ?? unansweredBridgeQuestions)() const claimed = new Set() const items: OpenQuestion[] = [] diff --git a/packages/framework/src/data-branch.SPEC.md b/packages/framework/src/data-branch.SPEC.md index 92285fe1c..0946dfadb 100644 --- a/packages/framework/src/data-branch.SPEC.md +++ b/packages/framework/src/data-branch.SPEC.md @@ -1,4 +1,4 @@ -The data branch `tf-data`: the one branch of a project's repository that holds everything The Framework itself writes — the tickets, the agent queue, the agent archives — so the project's own branches stay 100% code. This is where that branch is created, kept in step with the remote, written to, and read from. +The data branch `agents-data`: the one branch of a project's repository that holds everything The Framework itself writes — the tickets, the agent queue, the agent archives — so the project's own branches stay 100% code. This is where that branch is created, kept in step with the remote, written to, and read from. ## User story @@ -6,9 +6,9 @@ The user works from two machines and lets agents run in the cloud. The roadmap, ## Business logic — TL;DR -- **One branch for the framework's own files** - everything The Framework writes lives on `tf-data`, the way a site's published files live on their own branch, so the code branches carry no framework data. +- **One branch for the framework's own files** - everything The Framework writes lives on `agents-data`, the way a site's published files live on their own branch, so the code branches carry no framework data. - **The branch is born unattached to the code** - its first commit has no parent and no content, so no code commit is ever an ancestor of the data history. -- **Its checkout, and the roadmap shortcut** - the branch is checked out under `.the-framework/branches/tf-data`, and the repo root gets a `tickets` link into it that git is told to ignore. +- **Its checkout, and the roadmap shortcut** - the branch is checked out under `.branches/agents-data`, and the repo root gets a `tickets` link into it that git is told to ignore. - **One local writer, one funnel** - every local write goes through a single cycle per project — sync, apply, commit, push — serialized so two of them can never interleave. - **A write is an intent, not a commit** - the change is re-applied against fresher state when the push loses a race, rather than force-fitting a stale commit. - **A push is owed until it lands** - a commit that could not be pushed stays local and is carried by the next cycle. @@ -27,7 +27,7 @@ The user reviews a pull request and sees only code — no ticket churn, no queue #### Business logic -Every file The Framework itself writes — the tickets, the agent queue, the agent archives — lives on one branch of the project's repository, `tf-data`. Because nothing on that branch is anyone's working tree, it is safe to push and pull eagerly, which is what gives every machine and every cloud session the same view: fetch the branch, read the files, commit onto it, push. +Every file The Framework itself writes — the tickets, the agent queue, the agent archives — lives on one branch of the project's repository, `agents-data`. Because nothing on that branch is anyone's working tree, it is safe to push and pull eagerly, which is what gives every machine and every cloud session the same view: fetch the branch, read the files, commit onto it, push. The branch is created from the remote's copy when there is one — the case on every machine after the first — and otherwise born locally. @@ -49,7 +49,7 @@ The user wants to read the roadmap by listing the repository, not by learning wh #### Business logic -The branch is checked out at `.the-framework/branches/tf-data`, named after its branch like every other checkout there. The repository root gets a `tickets` link pointing into that checkout, so the roadmap is one listing away. +The branch is checked out at `.branches/agents-data`, named after its branch like every other checkout there. The repository root gets a `tickets` link pointing into that checkout, so the roadmap is one listing away. The link is created only where nothing already exists — a real `tickets` directory from before, or a file of the user's own, is left alone. Because the link is framework state and lives uncommitted at the repository root, it is hidden from git the moment it is made, or it would ride along on any sweeping commit onto a code branch. Hiding it takes a pair of rules, because the repository-level ignore speaks for every checkout at once — including the data checkout, whose own root holds the real `tickets` directory the branch exists to carry: one rule hides root entries of that name, and a second re-includes directories, which never matches a link. So the link stays hidden while the data checkout's own directory keeps being committed. diff --git a/packages/framework/src/data-branch.ts b/packages/framework/src/data-branch.ts index 60d58b3fc..58e4e6f7a 100644 --- a/packages/framework/src/data-branch.ts +++ b/packages/framework/src/data-branch.ts @@ -1,12 +1,12 @@ import { dirname, join } from 'node:path' -import { nodeGitRunner, type GitRunner, excludeFromGit } from '@gemstack/skill-branches' +import { BRANCHES_DIR, nodeGitRunner, type GitRunner, excludeFromGit } from '@gemstack/skill-branches' import { DATA_BRANCH, DATA_CHECKOUT_DIR } from './framework-dir.js' import { TICKETS_DIR, FLAT_TODO_FILE } from './tickets.js' import { errorMessage } from './error-message.js' export { DATA_BRANCH } -// The `tf-data` branch (#1582): every file The Framework itself writes — the tickets, +// The `agents-data` branch (#1582): every file The Framework itself writes — the tickets, // the queue, the session archives — lives on one branch of the project repo, the way `gh-pages` // holds a site's data. Main is 100% code, 0% framework data. // @@ -24,8 +24,9 @@ export { DATA_BRANCH } // its own checkout and pushes, and the race is settled by the push itself — whoever loses re-syncs // and re-applies. // -// The checkout is a plain git worktree at {@link DATA_CHECKOUT_DIR}. The repo root keeps a -// `tickets` symlink into it so the roadmap stays one `ls` away for humans. +// The checkout is a plain git worktree at {@link DATA_CHECKOUT_DIR}, under `.branches/` beside +// the agent checkouts. The repo root keeps a `tickets` symlink into it so the roadmap stays one +// `ls` away for humans. /** The data branch's checkout under a project. */ export function dataWorktreePath(cwd: string): string { @@ -129,13 +130,16 @@ async function ensureCore(cwd: string, r: Resolved): Promise { if (await refExists(cwd, `refs/remotes/origin/${DATA_BRANCH}`, r.git)) { await r.git(['branch', DATA_BRANCH, `origin/${DATA_BRANCH}`], cwd) } else { - const commit = (await r.git(['commit-tree', EMPTY_TREE, '-m', '[The Framework] tf-data'], cwd)).trim() + const commit = (await r.git(['commit-tree', EMPTY_TREE, '-m', `[The Framework] ${DATA_BRANCH}`], cwd)).trim() await r.git(['branch', DATA_BRANCH, commit], cwd) } } // A stale registration at this path (the dir was deleted by hand) blocks the add. await r.git(['worktree', 'prune'], cwd).catch(() => {}) await r.git(['worktree', 'add', path, DATA_BRANCH], cwd) + // The checkout sits under `.branches/` like the agent checkouts, and is hidden the same way + // (git's own exclude, no tracked file) — this may be the first checkout the project gets. + await excludeFromGit(cwd, '/' + BRANCHES_DIR, undefined, r.git).catch(() => {}) } // Seed the queue on a branch born empty, so readers and humans find the file, not a mystery. // Committed here, so the checkout is clean between cycles and no later write's message lies diff --git a/packages/framework/src/framework-dir.SPEC.md b/packages/framework/src/framework-dir.SPEC.md index 51ff1a1dd..8ee556c68 100644 --- a/packages/framework/src/framework-dir.SPEC.md +++ b/packages/framework/src/framework-dir.SPEC.md @@ -1,6 +1,4 @@ -Names `.the-framework/` — the directory under a project root where The Framework keeps its own files — and the data branch, `tf-data`, with the place its checkout sits under a project. Kept on its own so browser-side surfaces (the dashboard renders preset file paths built from it) can name paths under it without touching any node-only module. - -The data checkout's place, `.the-framework/branches/tf-data`, is temporary (#1736): the agent checkouts moved to the `skill-branches` package's `.branches/`, and the data checkout stays where it has always been until the convention for where a data branch lives is decided. +Names `.the-framework/` — the directory under a project root where The Framework keeps its own files — and the data branch, `agents-data`, with the place its checkout sits under a project: `.branches/agents-data`, beside the agent checkouts and named as its branch like each of them. Kept on its own so browser-side surfaces (the dashboard renders preset file paths built from it) can name paths under it without touching any node-only module. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/framework-dir.ts b/packages/framework/src/framework-dir.ts index bd688b44f..4bda02ac0 100644 --- a/packages/framework/src/framework-dir.ts +++ b/packages/framework/src/framework-dir.ts @@ -1,3 +1,5 @@ +import { BRANCHES_DIR } from '@gemstack/skill-branches/branch-names' + /** * The directory, under a project root, that holds The Framework's own files. * @@ -8,11 +10,10 @@ export const THE_FRAMEWORK_DIR = '.the-framework' /** The branch holding everything The Framework writes (#1582): the tickets, the queue, the session archives. */ -export const DATA_BRANCH = 'tf-data' +export const DATA_BRANCH = 'agents-data' /** - * The data branch's checkout under a project, relative to the project root. A temporary spelling - * (#1736): the agent checkouts moved to `.branches/`, and the data checkout stays where it has - * always been until the convention for where a data branch lives is decided. + * The data branch's checkout under a project, relative to the project root: `.branches/agents-data`, + * beside the agent checkouts and named as its branch like each of them (#1736). */ -export const DATA_CHECKOUT_DIR = `${THE_FRAMEWORK_DIR}/branches/${DATA_BRANCH}` +export const DATA_CHECKOUT_DIR = `${BRANCHES_DIR}/${DATA_BRANCH}` diff --git a/packages/framework/src/framework-gitignore.ts b/packages/framework/src/framework-gitignore.ts index dddd54679..6d1ec710e 100644 --- a/packages/framework/src/framework-gitignore.ts +++ b/packages/framework/src/framework-gitignore.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { THE_FRAMEWORK_DIR } from './framework-dir.js' +import { DATA_BRANCH, THE_FRAMEWORK_DIR } from './framework-dir.js' import { LAYOUT_FILE } from './layout.js' /** @@ -18,5 +18,5 @@ export function gitignorePath(cwd: string): string { /** The whole file: everything under `.the-framework/` is transient except the layout marker (#1582, #1575). */ export function frameworkGitignore(): string { - return `# The Framework: agent state is transient; the lasting records live on the tf-data branch.\n*\n!.gitignore\n!${LAYOUT_FILE}\n` + return `# The Framework: agent state is transient; the lasting records live on the ${DATA_BRANCH} branch.\n*\n!.gitignore\n!${LAYOUT_FILE}\n` } diff --git a/packages/framework/src/install.test.ts b/packages/framework/src/install.test.ts index 85d5214fd..00fc52c1b 100644 --- a/packages/framework/src/install.test.ts +++ b/packages/framework/src/install.test.ts @@ -92,11 +92,9 @@ test('installProject seeds .the-framework/.gitignore ignoring everything transie await installProject(CWD, { git, fs }) const ignore = fs.files.get(gitignorePath(CWD)) ?? '' // Everything under .the-framework/ is transient on main: the lasting records live on the data - // branch (#1582), so nothing is un-ignored except the file itself. - assert.match(ignore, /^\*$/m) - assert.match(ignore, /^!\.gitignore$/m) - assert.doesNotMatch(ignore, /agents/) - assert.doesNotMatch(ignore, /sessions/) + // branch (#1582), so nothing is un-ignored except the file itself and the layout marker (#1575). + const rules = ignore.split('\n').filter(line => line && !line.startsWith('#')) + assert.deepEqual(rules, ['*', '!.gitignore', '!LAYOUT']) }) test('installProject records the layout marker, tracked, so a skewed build is refused (#1575)', async () => { diff --git a/packages/framework/src/routine-locks.test.ts b/packages/framework/src/routine-locks.test.ts index 59f5414cc..76ccd8d6b 100644 --- a/packages/framework/src/routine-locks.test.ts +++ b/packages/framework/src/routine-locks.test.ts @@ -14,9 +14,10 @@ import { type RoutineLockDeps, } from './routine-locks.js' import { DATA_BRANCH, dataWorktreePath, type withDataBranch } from './data-branch.js' +import { DATA_CHECKOUT_DIR } from './framework-dir.js' import { nodeGitRunner } from '@gemstack/skill-branches' const CWD = '/repo' -const DATA = join(CWD, '.the-framework', 'branches', 'tf-data') +const DATA = join(CWD, '.the-framework', 'branches', 'agents-data') const T0 = Date.parse('2026-08-23T10:00:00.000Z') /** An in-memory data checkout behind a fake funnel, like ticket-locks.test.ts's. */ @@ -120,7 +121,7 @@ test("on boot, this machine's locks whose run is gone are released; a run still // Against real git: two clones of one bare origin, each with its own data checkout — the // cross-machine race the lock exists for. No funnel fake: what the other machine sees is what -// origin's `tf-data` holds after the push. +// origin's `agents-data` holds after the push. const git = nodeGitRunner() const RETRIED_RM = { recursive: true, force: true, maxRetries: 10 } as const @@ -136,7 +137,7 @@ async function initRepo(prefix: string, email: string): Promise { return repo } -test('two machines sharing tf-data: the second finds the first machine\'s lock on origin, and its release frees it (#1659, real git)', async () => { +test('two machines sharing agents-data: the second finds the first machine\'s lock on origin, and its release frees it (#1659, real git)', async () => { const laptop = await initRepo('framework-routine-lock-a-', 'a@a') const bare = await realpath(await mkdtemp(join(tmpdir(), 'framework-routine-lock-bare-'))) await git(['init', '--bare', bare], bare) @@ -151,7 +152,7 @@ test('two machines sharing tf-data: the second finds the first machine\'s lock o assert.deepEqual(await acquireRoutineLock(laptop, 'triage-quick', { host: 'laptop' }), { ok: true }) const onLaptop = await readFile(join(dataWorktreePath(laptop), 'routines', 'triage-quick.lock.md'), 'utf8') assert.match(onLaptop, /^CLAIMED: laptop\nSINCE: \d{4}-/) - // Pushed: origin's tf-data carries it. + // Pushed: origin's agents-data carries it. assert.equal((await git(['show', `${DATA_BRANCH}:routines/triage-quick.lock.md`], bare)).trim(), onLaptop.trim()) const onDesktop = await acquireRoutineLock(desktop, 'triage-quick', { host: 'desktop' }) diff --git a/packages/framework/src/routine-locks.ts b/packages/framework/src/routine-locks.ts index 09de7019a..396915d34 100644 --- a/packages/framework/src/routine-locks.ts +++ b/packages/framework/src/routine-locks.ts @@ -9,13 +9,13 @@ import { resolveDataFileDeps, type DataFileDeps } from './data-branch.js' // machines, and locally it was a name the *agent* checked from its prompt — every false abort // spent a started agent. The lock is the ticket claim (#1420) applied to a routine: minted by the // sweep through the data-branch write funnel before the run starts, so the daemon decides and no -// agent is started to find out, and read by every machine that shares `tf-data`. +// agent is started to find out, and read by every machine that shares `agents-data`. // // Release is the daemon's, not a PR's: a triage never opens one. The daemon that minted a lock // drops it when the run ends, whatever the ending, and on boot for any it holds whose run is // gone — so a crash on this machine frees the routine at once. A lock another machine left // behind counts as dead after {@link ROUTINE_LOCK_TTL_MS}, fixed: a triage over hundreds of -// tickets can take hours, and a heartbeat would cost code and `tf-data` churn. +// tickets can take hours, and a heartbeat would cost code and `agents-data` churn. /** Where routine state lives on the data branch; only the lock files, for now (#1660). */ const ROUTINES_DIR = 'routines' diff --git a/packages/framework/src/store/agent-checkout.test.ts b/packages/framework/src/store/agent-checkout.test.ts index b086edee5..17214a839 100644 --- a/packages/framework/src/store/agent-checkout.test.ts +++ b/packages/framework/src/store/agent-checkout.test.ts @@ -6,7 +6,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' import { resolveAgentEventsPath } from './agent-checkout.js' import { EVENTS_FILE, ARCHIVE_DIR } from './agent-store.js' import { worktreePath } from '@gemstack/skill-branches' -import { THE_FRAMEWORK_DIR } from '../framework-dir.js' +import { DATA_CHECKOUT_DIR, THE_FRAMEWORK_DIR } from '../framework-dir.js' // resolveAgentEventsPath probes the real filesystem (same as resolveAgentCheckout), so these // tests build a throwaway project directory rather than a memory fs. @@ -62,7 +62,7 @@ test('resolveAgentEventsPath: an ended run (worktree gone) resolves to its archi test('resolveAgentEventsPath: finds an archive filed under a user dir on the data branch (#1179/#1582)', async () => { const cwd = await makeProject() try { - const events = await seedArchive(cwd, join(cwd, THE_FRAMEWORK_DIR, 'branches', 'tf-data', ARCHIVE_DIR, 'someone')) + const events = await seedArchive(cwd, join(cwd, DATA_CHECKOUT_DIR, ARCHIVE_DIR, 'someone')) assert.equal(await resolveAgentEventsPath(cwd, RUN_ID), events) } finally { await rm(cwd, { recursive: true, force: true }) diff --git a/packages/framework/src/store/agent-store.test.ts b/packages/framework/src/store/agent-store.test.ts index dc9aec8a5..0202ea8a9 100644 --- a/packages/framework/src/store/agent-store.test.ts +++ b/packages/framework/src/store/agent-store.test.ts @@ -18,6 +18,7 @@ import { type StoreFs, type AgentMeta, } from './agent-store.js' +import { DATA_CHECKOUT_DIR } from '../framework-dir.js' import type { FrameworkEvent } from '../events.js' /** An in-memory {@link StoreFs} so the store logic is tested without touching disk. */ @@ -482,7 +483,7 @@ test('listAgents reads every user archive and the transient one, under their one // archives that, with no users, nobody has. const meta = (id: string): string => JSON.stringify({ status: 'done', id, startedAt: AT, updatedAt: AT, intent: id }) - const user = join(CWD, '.the-framework', 'branches', 'tf-data', 'agents', 'dev@example.com') + const user = join(CWD, DATA_CHECKOUT_DIR, 'agents', 'dev@example.com') const fs = memFs({ [join(user, '2026-new.json')]: meta('2026-new'), [join(CWD, '.the-framework', 'agents', '2026-transient.json')]: meta('2026-transient'), @@ -706,7 +707,7 @@ test('archiveWorktreeAgent is forgiving of a worktree with no run', async () => const USER = 'git@brillout.com' const archiveAt = (id: string, ext: string) => - join(CWD, '.the-framework', 'branches', 'tf-data', 'agents', USER, `${id}.${ext}`) + join(CWD, DATA_CHECKOUT_DIR, 'agents', USER, `${id}.${ext}`) test('a named user files the archive under their own sessions, not runs/ (#1179)', async () => { // The whole point: `agents/` is gitignored, so a `git clean -fdx` took every session with it. @@ -723,7 +724,7 @@ test('the history lists every user, and the runs archived before this shipped (# const fs = memFs({ [join(CWD, '.the-framework', 'agents', 'r1.json')]: done('r1'), [archiveAt('r2', 'json')]: done('r2'), - [join(CWD, '.the-framework', 'branches', 'tf-data', 'agents', 'someone@else.com', 'r3.json')]: done('r3'), + [join(CWD, DATA_CHECKOUT_DIR, 'agents', 'someone@else.com', 'r3.json')]: done('r3'), }) assert.deepEqual((await listAgents(CWD, fs)).map(agent => agent.id), ['r3', 'r2', 'r1']) }) diff --git a/packages/framework/src/ticket-locks.test.ts b/packages/framework/src/ticket-locks.test.ts index 4987c3f0c..f7a0ae3c7 100644 --- a/packages/framework/src/ticket-locks.test.ts +++ b/packages/framework/src/ticket-locks.test.ts @@ -13,10 +13,11 @@ import { type TicketLockDeps, } from './ticket-locks.js' import type { DataWriteResult, withDataBranch } from './data-branch.js' +import { DATA_CHECKOUT_DIR } from './framework-dir.js' const CWD = '/repo' /** Where the funnel's op runs: the data branch's checkout (#1582). */ -const DATA = join(CWD, '.the-framework', 'branches', 'tf-data') +const DATA = join(CWD, DATA_CHECKOUT_DIR) /** * An in-memory data checkout behind a fake write funnel: the op runs against a file map, the From 927cf896592361c463cc45cb20e60c581cb5593d Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 28 Aug 2026 22:11:19 +0300 Subject: [PATCH 2/2] The branches skill loads as a skill, not from the prompt (#1739) --- FEATURES-SPEC.md | 2 +- packages/framework/prompts/SPEC.md | 2 +- .../framework/prompts/system_prompt.SPEC.md | 4 +- packages/framework/prompts/system_prompt.md | 2 +- packages/framework/scripts/gen-prompts.mjs | 20 ++----- packages/framework/src/system-prompt.SPEC.md | 4 +- packages/framework/src/system-prompt.test.ts | 32 ++++++----- packages/framework/src/system-prompt.ts | 16 +++--- packages/skill-branches/SPEC.md | 5 +- packages/skill-branches/package.json | 3 +- packages/skill-branches/src/checkout.SPEC.md | 2 +- packages/skill-branches/src/checkout.ts | 7 ++- packages/skill-branches/src/index.ts | 1 + .../skill-branches/src/skill-links.SPEC.md | 11 ++++ .../skill-branches/src/skill-links.test.ts | 53 +++++++++++++++++++ packages/skill-branches/src/skill-links.ts | 53 +++++++++++++++++++ 16 files changed, 163 insertions(+), 54 deletions(-) create mode 100644 packages/skill-branches/src/skill-links.SPEC.md create mode 100644 packages/skill-branches/src/skill-links.test.ts create mode 100644 packages/skill-branches/src/skill-links.ts diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 50947687e..43bed1813 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -109,7 +109,7 @@ happens while nobody is at the keyboard. ## Handoff and what lands in git - Every agent gets its own git worktree under `.branches/` and its own branch (`agent-`), created before it starts; your checkout is never touched -- The `branches` skill: the system prompt of every agent started in its own checkout carries the `@gemstack/skill-branches` package's `SKILL.md` — that checkout is your whole workspace, name the session with the command, commit as you go, leave a clean tree, never push or open the PR yourself; an agent anywhere else (a terminal run in your checkout, an Actions runner, a cloud session) is told to branch with git itself +- The `branches` skill: every checkout The Framework creates carries the `@gemstack/skill-branches` package's `SKILL.md` where the agent's harness looks for skills (`.claude/skills/branches` for Claude Code, `.agents/skills/branches` for Codex), and the system prompt tells the agent to use it — that checkout is your whole workspace, name the session with the command, commit as you go, leave a clean tree, never push or open the PR yourself; an agent anywhere else (a terminal run in your checkout, an Actions runner, a cloud session) is told to branch with git itself - `branches` on every agent's PATH — the skill's command line: `name ` renames the agent's branch to `agent-` and prints the name it got (suffixed when taken); `status` says whether the tree is clean and the branch on the remote; `create`, `attach`, `list`, `remove`, `prune` are the same operations the dashboard runs - The session name is the branch: an agent is labelled by its `agent-` branch, read from git after every turn — nothing to signal, nothing to record twice - Dependency directories shared from the parent checkout instead of reinstalled — as directories of links, so an agent's own install stays in its checkout and never rewrites or purges the parent's diff --git a/packages/framework/prompts/SPEC.md b/packages/framework/prompts/SPEC.md index cca56c242..71ae282ef 100644 --- a/packages/framework/prompts/SPEC.md +++ b/packages/framework/prompts/SPEC.md @@ -10,7 +10,7 @@ Every prompt The Framework sends an agent lives here as markdown. Nothing agent- - **Markdown is the only source of truth** - the prompts are compiled into the package at build time and the code uses the compiled copies; a prompt is edited in exactly one place. - **The built-in system prompt** - the standing instructions every agent starts with: analyze the prompt, park at a gate when the user should decide, name the session before the first change, declare ready for merge only when finished. - **The file formats** - the shapes agents must follow for tickets and for the agent queue, carried in the agent's own context rather than pointed at. -- **Branch management** - the section after the built-in prompt saying how the agent names its branch: the `skill-branches` package's own skill for an agent in a checkout The Framework created, else the fallback that has it branch with git itself. +- **Branch management** - the section after the built-in prompt for an agent outside a checkout The Framework created: the fallback that has it branch with git itself. An agent in such a checkout has the `branches` skill in the checkout instead, and the prompt sends it there. - **The data-branch protocol** - where the framework's own data lives and how to read and write it without putting it on a code branch. - **The protocols** - how an agent signals: awaited choices and ready for merge, plus the sections added only when it has a browser, when it runs hands-off, and when nothing can answer its questions. - **The presets** - one file per launcher button and per routine prompt. diff --git a/packages/framework/prompts/system_prompt.SPEC.md b/packages/framework/prompts/system_prompt.SPEC.md index b06683f0e..a7e52643e 100644 --- a/packages/framework/prompts/system_prompt.SPEC.md +++ b/packages/framework/prompts/system_prompt.SPEC.md @@ -14,7 +14,7 @@ The Framework's built-in system prompt: the standing instructions every agent st - **Analyze the prompt before working** - an unclear prompt becomes a plausibility-ranked list of interpretations offered as a choice, and the agent waits for the answer. - **Large scope gets approved first** - large work is written up as a plan file shown to the user and awaits approval; very large work also seeds follow-up entries onto the agent queue. -- **Name the session** - before the first change the agent names the session and its branch after it, as the "Branch management" section says; the name it ends up with is the session name. +- **Name the session** - before the first change the agent names the session and its branch after it with the `branches` skill — or, where the skill is not available, as the "Branch management" section says; the name it ends up with is the session name. - **Rate variability, offer alternatives** - each problem about to be solved is scored on how obviously optimal its solution is; low scorers are explored and their alternatives offered as a choice. - **Ready for merge is explicit** - the agent signals it only when the task is finished; otherwise it states what remains. - **The user's prompt is the last section** - the built-in instructions frame the system channel, and the user's own prompt is delivered as its own half. @@ -51,7 +51,7 @@ See `## User story`: work must be reviewable as a pull request on a branch named #### Business logic -Before applying its first change the agent picks a session name — an `[a-z0-9-]+` string that succinctly captures the intent of the user's prompt — and names its branch after it the way the "Branch management" section appended after the prompt says: the `branches` skill's command for an agent in a checkout The Framework created, git itself anywhere else. When the name the agent ends up with differs (the skill's command suffixes a taken name), that is the session name from then on. +Before applying its first change the agent picks a session name — an `[a-z0-9-]+` string that succinctly captures the intent of the user's prompt — and names its branch after it with the `branches` skill, which an agent in a checkout The Framework created finds in that checkout; where the skill is not available, the "Branch management" section appended after the prompt has it branch with git itself. When the name the agent ends up with differs (the skill's command suffixes a taken name), that is the session name from then on. #### Rationale diff --git a/packages/framework/prompts/system_prompt.md b/packages/framework/prompts/system_prompt.md index fbe025a9f..947c38821 100644 --- a/packages/framework/prompts/system_prompt.md +++ b/packages/framework/prompts/system_prompt.md @@ -27,7 +27,7 @@ Do the following before applying your first change. ### Session name 1. Create a as a string [a-z0-9-]+ that succinctly represents the intention of the user prompt -2. Name your branch after it, as the "Branch management" section below says — and if the name you end up with differs, that is your from now on +2. Name your branch after it: use the `branches` skill, which says how. Where that skill is not available, the "Branch management" section below says how instead. If the name you end up with differs, that is your from now on ## Before applying changes diff --git a/packages/framework/scripts/gen-prompts.mjs b/packages/framework/scripts/gen-prompts.mjs index f301bb001..471814256 100644 --- a/packages/framework/scripts/gen-prompts.mjs +++ b/packages/framework/scripts/gen-prompts.mjs @@ -1,5 +1,4 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' -import { createRequire } from 'node:module' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' @@ -42,25 +41,16 @@ function constName(relPath) { .toUpperCase() } -// The `branches` skill (#1725) rides in the system channel the way the prompts above do, -// but its text is the package's, not this directory's: read from wherever the package is -// installed, so the instructions and the command they name can never come from two versions. -// Its front matter is the skill catalogue's metadata, not instructions, and is dropped. -const skillPath = createRequire(import.meta.url).resolve('@gemstack/skill-branches/SKILL.md') -const sources = [ - ...(await findMarkdown(promptsDir)).map(path => { - const relPath = relative(promptsDir, path).split('\\').join('/') - return { label: `prompts/${relPath}`, name: constName(relPath), path, frontMatter: false } - }), - { label: '@gemstack/skill-branches/SKILL.md', name: 'BRANCHES_SKILL', path: skillPath, frontMatter: true }, -] +const sources = (await findMarkdown(promptsDir)).map(path => { + const relPath = relative(promptsDir, path).split('\\').join('/') + return { label: `prompts/${relPath}`, name: constName(relPath), path } +}) const entries = await Promise.all( sources.map(async source => { const raw = await readFile(source.path, 'utf8') - const text = source.frontMatter ? raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n+/, '') : raw // Strip exactly one trailing newline: the files end with one so they are well-formed on // disk, the prompts they carry do not. - return { ...source, text: text.replace(/\n$/, '') } + return { ...source, text: raw.replace(/\n$/, '') } }), ) diff --git a/packages/framework/src/system-prompt.SPEC.md b/packages/framework/src/system-prompt.SPEC.md index f3ad0c1af..c289a8012 100644 --- a/packages/framework/src/system-prompt.SPEC.md +++ b/packages/framework/src/system-prompt.SPEC.md @@ -18,7 +18,7 @@ Composes an agent's system channel — the built-in system prompt, the repo-cont - **The built-in system prompt is a template in two halves** - the system half frames the session; the user-prompt half is the slot the user's prompt lands in. The boundary is fixed by the template itself, so a user prompt that contains the boundary heading can never move it. - **The agent's repo context rides in the channel** - a `Context:` block lists the user's picked directories and then the framework's context docs, each with a one-line gloss; the two format-bearing docs point at format specs that travel in the same channel rather than at files to go open. -- **Composition is additive and ordered** - context first, then the format specs, the built-in prompt, the "Branch management" section (the `branches` skill in a checkout The Framework created, else the section that has the agent branch with git itself), and the user's `SYSTEM.md`; a repo can keep the built-in prompt *and* add its own, replace it, or leave both off. +- **Composition is additive and ordered** - context first, then the format specs, the built-in prompt, then — for an agent outside a checkout The Framework created — the "Branch management" section that has it branch with git itself, and the user's `SYSTEM.md`; a repo can keep the built-in prompt *and* add its own, replace it, or leave both off. - **Vanilla drops everything framework-authored except the emit protocols** - the built-in prompt, the context docs, and the format specs all go together; the user's own dirs and `SYSTEM.md` survive, and the emit protocols stay because they are the contract the dashboard's gates run on, not prompt content. - **Transparent drops the whole channel** - no prompt, no docs, no protocols: the agent runs byte-identical to the raw wrapped CLI. It overrides every other option. - **Per-agent capability sections** - an agent with a real browser attached is told so; a hands-off agent is told to land its own work; the signal protocol is always the last thing in the channel. @@ -33,7 +33,7 @@ The user types one prompt; the agent must analyze it before coding — an ambigu #### Business logic -The built-in system prompt is a template whose text lives in the package's `prompts/system_prompt.md`. It carries the analyze-the-prompt flow, the session-name step, the alternatives flow, and the after-changes steps; the workspace rules are the `branches` skill's. The "Branch management" section is appended right after the built-in prompt, before the user's own system prompt: for an agent in a checkout The Framework created — a daemon-started agent on this machine, with `branches` on its PATH — it is the `skill-branches` package's own `SKILL.md`, read from wherever that package is installed with its catalogue front matter dropped; for any other agent (a terminal run in the user's own checkout, a GitHub Actions runner, a cloud session) it is the fallback that has the agent create its `agent-` branch with git itself. Both are framework-authored, so vanilla drops them with the built-in prompt — the session-name step is exactly what the vanilla follow-up must not run. +The built-in system prompt is a template whose text lives in the package's `prompts/system_prompt.md`. It carries the analyze-the-prompt flow, the session-name step, the alternatives flow, and the after-changes steps; the workspace rules are the `branches` skill's. For an agent in a checkout The Framework created — a daemon-started agent on this machine, with `branches` on its PATH — the session-name step sends it to the `branches` skill, which the `skill-branches` package links into the checkout where the agent's harness looks for skills; nothing about the workspace rides in the channel. For any other agent (a terminal run in the user's own checkout, a GitHub Actions runner, a cloud session) the "Branch management" section is appended right after the built-in prompt, before the user's own system prompt: the fallback that has the agent create its `agent-` branch with git itself. It is framework-authored, so vanilla drops it with the built-in prompt — the session-name step is exactly what the vanilla follow-up must not run. ### The repo context and the formats that travel with it diff --git a/packages/framework/src/system-prompt.test.ts b/packages/framework/src/system-prompt.test.ts index a914e81c1..09792502d 100644 --- a/packages/framework/src/system-prompt.test.ts +++ b/packages/framework/src/system-prompt.test.ts @@ -12,7 +12,7 @@ import { SYSTEM_PROMPT_TEMPLATE, } from './system-prompt.js' import { FLAT_TODO_FILE } from './tickets.js' -import { BRANCHES_SKILL, BRANCH_YOURSELF, DATA_BRANCH_PROTOCOL, TICKETING_FORMAT, TODO_FORMAT } from './prompts.generated.js' +import { BRANCH_YOURSELF, DATA_BRANCH_PROTOCOL, TICKETING_FORMAT, TODO_FORMAT } from './prompts.generated.js' import { loadUserSystemPrompt, SYSTEM_PROMPT_FILE } from './system-prompt-file.js' import { THE_FRAMEWORK_DIR } from './framework-dir.js' @@ -105,7 +105,8 @@ test('SYSTEM_PROMPT_TEMPLATE carries the built-in prompt sections (#326) verbati // cwd — nothing else in the prompt tells it where its workspace ends. // The workspace rules moved to the `branches` skill (#1725); the prompt keeps the step that names the session. assert.ok(!SYSTEM_PROMPT_TEMPLATE.includes('Your working directory is the whole of your workspace')) - assert.ok(SYSTEM_PROMPT_TEMPLATE.includes('as the "Branch management" section below says')) + assert.ok(SYSTEM_PROMPT_TEMPLATE.includes('use the `branches` skill, which says how')) + assert.ok(SYSTEM_PROMPT_TEMPLATE.includes('Where that skill is not available, the "Branch management" section below says how instead')) assert.ok(SYSTEM_PROMPT_TEMPLATE.includes('${{tf.prompt}}')) // The whole block is the branch-free doc now: #326 moved the one `tf.params.autopilot` // ternary out with the maintenance section, so `tf.prompt` is the only fragment left. @@ -304,32 +305,29 @@ test('composeAgentSystem is empty under transparent mode — no prompt, no emit ) }) -test('the `branches` skill rides after the built-in prompt of an agent in its own checkout; anyone else branches with git (#1725)', () => { - // The skill is the package's SKILL.md, front matter stripped: instructions, not catalogue metadata. - assert.ok(BRANCHES_SKILL.startsWith('# Branch management'), BRANCHES_SKILL.slice(0, 40)) - assert.ok(BRANCHES_SKILL.includes('branches name ')) - assert.ok(BRANCHES_SKILL.includes('branches status')) +test('an agent in its own checkout is sent to the `branches` skill, which the checkout carries; anyone else branches with git (#1725, #1739)', () => { const block = systemPromptBlock({ ownedCheckout: true, user: 'Mine too.' }) - const prompt = block.indexOf('### Session name') - const skill = block.indexOf(BRANCHES_SKILL) - assert.ok(prompt !== -1 && skill !== -1 && prompt < skill, 'the prompt names the session first, the skill says how') - assert.ok(skill < block.indexOf('Mine too.'), "the user's own prompt stays last") + // The session-name step sends the agent to the skill. The skill itself is the checkout's — the + // skill-branches package links it where the agent's harness looks — so none of it rides here. + assert.ok(block.includes('use the `branches` skill')) + assert.ok(!block.includes('branches name ')) assert.ok(!block.includes(BRANCH_YOURSELF)) - // The prompt's session-name step defers to that section: no branch the agent creates itself, no signal. + assert.ok(block.indexOf('### Session name') < block.indexOf('Mine too.'), "the user's own prompt stays last") + // The step defers to the skill: no branch the agent creates itself, no signal. assert.ok(!block.includes('git checkout')) assert.ok(!block.includes('setSessionName')) // Outside a checkout The Framework created — a terminal run in the user's own checkout, an Actions - // runner, a cloud session — the command is not on the PATH: the same section title, git instead. + // runner, a cloud session — the command is not on the PATH: the section that has it branch with git. for (const opts of [{}, { handsOff: true }]) { const elsewhere = systemPromptBlock(opts) - assert.ok(!elsewhere.includes(BRANCHES_SKILL), JSON.stringify(opts)) + assert.ok(!elsewhere.includes('branches name '), JSON.stringify(opts)) assert.ok(elsewhere.includes(BRANCH_YOURSELF), JSON.stringify(opts)) assert.ok(BRANCH_YOURSELF.startsWith('# Branch management') && BRANCH_YOURSELF.includes('git checkout -b agent-')) } - // Framework-authored, so `--vanilla` drops both: the on-before-mergeable follow-up must not rename - // or create a branch after a session of its own (#560). + // Framework-authored, so `--vanilla` drops it with the prompt: the on-before-mergeable follow-up + // must not rename or create a branch after a session of its own (#560). for (const opts of [{ vanilla: true, ownedCheckout: true, user: 'Only mine.' }, { vanilla: true, user: 'Only mine.' }]) { const vanilla = systemPromptBlock(opts) - assert.ok(!vanilla.includes(BRANCHES_SKILL) && !vanilla.includes(BRANCH_YOURSELF) && !vanilla.includes('Branch management'), JSON.stringify(opts)) + assert.ok(!vanilla.includes(BRANCH_YOURSELF) && !vanilla.includes('Branch management') && !vanilla.includes('`branches` skill'), JSON.stringify(opts)) } }) diff --git a/packages/framework/src/system-prompt.ts b/packages/framework/src/system-prompt.ts index 1f1307147..d7ab6572e 100644 --- a/packages/framework/src/system-prompt.ts +++ b/packages/framework/src/system-prompt.ts @@ -1,5 +1,5 @@ import { renderTemplate } from './prompt-template.js' -import { BRANCHES_SKILL, BRANCH_YOURSELF, DATA_BRANCH_PROTOCOL, SYSTEM_PROMPT, TICKETING_FORMAT, TODO_FORMAT } from './prompts.generated.js' +import { BRANCH_YOURSELF, DATA_BRANCH_PROTOCOL, SYSTEM_PROMPT, TICKETING_FORMAT, TODO_FORMAT } from './prompts.generated.js' import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, HANDS_OFF_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js' // No Node imports here, deliberately. This module composes the prompt and the @@ -233,13 +233,13 @@ export function systemPromptBlock(opts: SystemPromptOptions = {}): string { parts.push([head, ...bullets].join('\n')) } // The formats the two format-bearing bullets name, right under the list that names them (#1163). - // The "Branch management" section (#1725) follows the built-in prompt, whose session-name step - // points at it. In a checkout The Framework created it is the package's own skill: the workspace, - // the `branches` command that names the branch, commit-as-you-go, the clean tree to - // leave. Anywhere else the command is not on the PATH, and the agent branches with git itself. - // Framework-authored either way, so `--vanilla` drops it — which is what keeps the - // on-before-mergeable follow-up from naming a session of its own (#560). - if (includeBuiltin) parts.push(...CONTEXT_FORMATS, renderSystemPrompt(opts.tf).system, opts.ownedCheckout ? BRANCHES_SKILL : BRANCH_YOURSELF) + // In a checkout The Framework created, the `branches` skill is the checkout's (#1739): the + // package links it where the agent's harness looks for skills, and the built-in prompt's + // session-name step tells the agent to use it — nothing rides in this channel. Anywhere else the + // command is not on the PATH, so the "Branch management" section that has the agent branch with + // git itself follows the prompt. Framework-authored, so `--vanilla` drops it — which is what + // keeps the on-before-mergeable follow-up from naming a session of its own (#560). + if (includeBuiltin) parts.push(...CONTEXT_FORMATS, renderSystemPrompt(opts.tf).system, ...(opts.ownedCheckout ? [] : [BRANCH_YOURSELF])) const user = opts.user?.trim() if (user) parts.push(user) return parts.join('\n\n') diff --git a/packages/skill-branches/SPEC.md b/packages/skill-branches/SPEC.md index d84bd7712..8f7b1c594 100644 --- a/packages/skill-branches/SPEC.md +++ b/packages/skill-branches/SPEC.md @@ -7,12 +7,13 @@ The package knows git and the filesystem, nothing else. The same functions serve - **The conventions** (`branch-names`) - branch names, the checkout directory layout under `.branches/`, and the agent-id charset every path is built from. - **Running git** (`git`) - one runner with a time budget per subcommand, and a timeout told apart from a git failure. - **A checkout's lifecycle** (`worktree`) - create, attach, list, name, remove, prune; the reads every retention decision is built on; the project a directory belongs to. -- **A checkout as an agent gets it** (`checkout`) - the worktree, `.branches/` hidden from git, the dependencies linked in, the links refreshed: one sequence for a daemon and the command line. +- **A checkout as an agent gets it** (`checkout`) - the worktree, `.branches/` hidden from git, the dependencies linked in, the skill linked in, the links refreshed: one sequence for a daemon and the command line. +- **The skill where the harness looks** (`skill-links`) - a link per harness in every checkout the package creates — `.claude/skills/branches`, `.agents/skills/branches` — to the package's own `SKILL.md`, hidden from git; one mechanism for every harness. - **Dependencies shared, not copied** (`worktree-deps`) - a fresh checkout gets the parent's dependency trees as directories of links. - **Reachable by branch name** (`branch-links`, `git-exclude`) - a symlink per current branch name beside the checkouts; the exclude that hides `.branches/` from the project's git. - **Reclaiming a checkout** (`reclaim`) - the one rule: keep a dirty tree, push the branch when allowed, remove only once the remote has it, and delete an agent branch that holds nothing. - **The command line** (`cli`, `bin/`) - the same operations as commands for a shell: JSON on stdout, a reason on stderr, an exit code that tells a refusal from a usage error; the executable's directory is exported (`bin-dir`) for a caller that spawns agents. -- **The skill** (`SKILL.md`) - what the agent is told: its checkout is its whole workspace, it names its session with `branches name` before its first change and uses the name the command prints, it commits as it goes, leaves a clean tree, and never publishes itself. A caller that starts agents puts it in the prompt of every agent it starts in a checkout it created. +- **The skill** (`SKILL.md`) - what the agent is told: its checkout is its whole workspace, it names its session with `branches name` before its first change and uses the name the command prints, it commits as it goes, leaves a clean tree, and never publishes itself. An agent finds it as a skill of its harness in every checkout the package creates; nothing has to be put in a prompt. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-branches/package.json b/packages/skill-branches/package.json index a072c837f..887e41cf5 100644 --- a/packages/skill-branches/package.json +++ b/packages/skill-branches/package.json @@ -31,8 +31,7 @@ "./branch-names": { "types": "./dist/branch-names.d.ts", "default": "./dist/branch-names.js" - }, - "./SKILL.md": "./SKILL.md" + } }, "scripts": { "build": "tsc -p tsconfig.build.json", diff --git a/packages/skill-branches/src/checkout.SPEC.md b/packages/skill-branches/src/checkout.SPEC.md index 3e94afa6f..2a57b0f6f 100644 --- a/packages/skill-branches/src/checkout.SPEC.md +++ b/packages/skill-branches/src/checkout.SPEC.md @@ -1,4 +1,4 @@ -A checkout as an agent gets it: the worktree on its branch, `.branches/` hidden from the project's git, the parent checkout's dependency directories linked in, and the `.branches/` links brought up to date — one sequence for a new agent (a fresh `agent-` branch, from a stated base or the project's head) and for a continued one (back on the branch its work is on). A daemon allocating a run and the command line both go through it, so the two never differ in what a checkout starts with. All of it is best-effort: a checkout without its dependencies is a worse run, not a failed one, and a missing link is made by the next reconcile pass. +A checkout as an agent gets it: the worktree on its branch, `.branches/` hidden from the project's git, the parent checkout's dependency directories linked in, the skill linked in where the agent's harness looks for it, and the `.branches/` links brought up to date — one sequence for a new agent (a fresh `agent-` branch, from a stated base or the project's head) and for a continued one (back on the branch its work is on). A daemon allocating a run and the command line both go through it, so the two never differ in what a checkout starts with. All of it is best-effort: a checkout without its dependencies is a worse run, not a failed one, and a missing link is made by the next reconcile pass. The checkouts are the package's state, not the project's: as an untracked directory at the root, `.branches/` would ride any sweeping `git add -A` onto a code branch, so it is hidden through the repository's own exclude file the moment the first checkout exists — no tracked file changes, and no user ever sees a diff. diff --git a/packages/skill-branches/src/checkout.ts b/packages/skill-branches/src/checkout.ts index 3601f192c..c1a0e6c83 100644 --- a/packages/skill-branches/src/checkout.ts +++ b/packages/skill-branches/src/checkout.ts @@ -4,11 +4,13 @@ import { addWorktree, attachWorktree, type AddedWorktree } from './worktree.js' import { linkDependencies } from './worktree-deps.js' import { reconcileBranchLinks } from './branch-links.js' import { excludeFromGit } from './git-exclude.js' +import { linkSkill } from './skill-links.js' /** * A checkout as an agent gets it (#1725): the worktree, `.branches/` hidden from git, the parent's - * dependency trees linked in, and the `.branches/` links brought up to date — one sequence, - * whichever surface asks for it (a daemon allocating a run, the command line). + * dependency trees linked in, the skill linked in where the agent's harness looks for it (#1739), + * and the `.branches/` links brought up to date — one sequence, whichever surface asks for it (a + * daemon allocating a run, the command line). */ /** A new agent's checkout, on a fresh `agent-` branch from `base` or the project's head. */ @@ -44,5 +46,6 @@ export async function attachCheckout( async function settle(repo: string, path: string, git: GitRunner): Promise { await excludeFromGit(repo, `/${BRANCHES_DIR}`, undefined, git).catch(() => {}) await linkDependencies(repo, path).catch(() => []) + await linkSkill(repo, path, undefined, git) await reconcileBranchLinks(repo, { git }).catch(() => {}) } diff --git a/packages/skill-branches/src/index.ts b/packages/skill-branches/src/index.ts index 2fb82386d..18e6029c8 100644 --- a/packages/skill-branches/src/index.ts +++ b/packages/skill-branches/src/index.ts @@ -48,3 +48,4 @@ export { projectRoot, nameBranch, isSessionName, type NameBranchOutcome, type Na export { createCheckout, attachCheckout } from './checkout.js' export { runCli, USAGE, type CliIo, type CliRefusal } from './cli.js' export { CLI_BIN_DIR } from './bin-dir.js' +export { linkSkill, HARNESS_SKILL_DIRS, SKILL_DIR, SKILL_NAME } from './skill-links.js' diff --git a/packages/skill-branches/src/skill-links.SPEC.md b/packages/skill-branches/src/skill-links.SPEC.md new file mode 100644 index 000000000..074975d8d --- /dev/null +++ b/packages/skill-branches/src/skill-links.SPEC.md @@ -0,0 +1,11 @@ +Puts the skill where the agent's harness looks for one. Every checkout the package creates gets a link per harness — under `.claude/skills/` for Claude Code, under `.agents/skills/` for Codex — named `branches` and pointing at the package, whose `SKILL.md` is the skill. The links are hidden from the project's git through the repository's own exclude file. + +## Business logic — TL;DR + +- **One mechanism for every harness** - each harness reads skills from a directory of its own at the checkout root; the package links the same skill into each, so every agent, whichever harness runs it, is told the same thing. A new harness is one more directory in the list. +- **The checkout is the place** - a checkout under `.branches/` is its own repository root to a harness, so a skill the project keeps at its own root never reaches the agent; the link has to be in the checkout. +- **Hidden, best-effort, idempotent** - the links are the package's state, not the agent's work: hidden from git so they never ride a commit, left alone where something already sits at their path, and a link that cannot be made means an agent without the skill, not a failed start. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-branches/src/skill-links.test.ts b/packages/skill-branches/src/skill-links.test.ts new file mode 100644 index 000000000..280ec55a8 --- /dev/null +++ b/packages/skill-branches/src/skill-links.test.ts @@ -0,0 +1,53 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { join } from 'node:path' +import { mkdtemp, readFile, realpath, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { nodeGitRunner } from './git.js' +import { createCheckout } from './checkout.js' +import { HARNESS_SKILL_DIRS, linkSkill, SKILL_DIR, SKILL_NAME } from './skill-links.js' + +const git = nodeGitRunner() + +async function repoWithOneCommit(): Promise { + const repo = await realpath(await mkdtemp(join(tmpdir(), 'skill-links-'))) + await git(['init', '-q', '-b', 'main'], repo) + await writeFile(join(repo, 'README.md'), 'hi\n') + await git(['add', '-A'], repo) + await git(['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init'], repo) + return repo +} + +test('a checkout the package creates carries the skill where every harness looks, hidden from git (#1739)', async () => { + const repo = await repoWithOneCommit() + try { + const { path } = await createCheckout(repo, { agentId: 'a1' }) + for (const dir of HARNESS_SKILL_DIRS) { + const link = join(path, dir, SKILL_NAME) + assert.equal(await realpath(link), await realpath(SKILL_DIR), `${dir} links to the package`) + // What the harness reads there is this package's skill, under the name it links as. + assert.match(await readFile(join(link, 'SKILL.md'), 'utf8'), new RegExp(`^name: ${SKILL_NAME}$`, 'm')) + } + // The links are the package's state, not the agent's work: nothing to commit, nothing to leave clean. + assert.equal((await git(['status', '--porcelain'], path)).trim(), '') + // Linking again changes nothing, so a continued agent's checkout can be settled as often as needed. + await linkSkill(repo, path) + assert.equal(await realpath(join(path, HARNESS_SKILL_DIRS[0], SKILL_NAME)), await realpath(SKILL_DIR)) + } finally { + await rm(repo, { recursive: true, force: true }) + } +}) + +test('an entry already at a link path is left alone (#1739)', async () => { + const repo = await repoWithOneCommit() + try { + const { path } = await createCheckout(repo, { agentId: 'a2' }) + const link = join(path, HARNESS_SKILL_DIRS[0], SKILL_NAME) + await unlink(link) + await writeFile(link, 'mine\n') + await linkSkill(repo, path) + assert.equal(await readFile(link, 'utf8'), 'mine\n') + } finally { + await rm(repo, { recursive: true, force: true }) + } +}) diff --git a/packages/skill-branches/src/skill-links.ts b/packages/skill-branches/src/skill-links.ts new file mode 100644 index 000000000..134f05c9d --- /dev/null +++ b/packages/skill-branches/src/skill-links.ts @@ -0,0 +1,53 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { nodeGitRunner, type GitRunner } from './git.js' +import { excludeFromGit } from './git-exclude.js' +import { nodeLinkFs, type LinkFs } from './worktree-deps.js' + +/** + * The skill, where the agent's harness looks for one (#1739). A harness discovers skills under a + * directory of its own at the checkout root — `.claude/skills//SKILL.md` for Claude Code, + * `.agents/skills//SKILL.md` for Codex — and a checkout under `.branches/` is its own + * repository root to it, so nothing the project keeps in those directories reaches the agent. + * Every checkout the package creates therefore gets one symlink per harness, named as the skill + * and pointing at this package, whose `SKILL.md` is the skill. One mechanism for every harness, + * and the same text for every agent; a new harness is one more entry in {@link HARNESS_SKILL_DIRS}. + */ + +/** The skill's name: the `name` in `SKILL.md`'s front matter, and the directory a harness lists it under. */ +export const SKILL_NAME = 'branches' + +/** Where each harness looks for skills, relative to the checkout root. */ +export const HARNESS_SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const + +/** + * This package's directory — the one holding `SKILL.md`. Beside `dist/`, so it is the same path + * from a workspace checkout and from an installed package. + */ +export const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), '..') + +/** + * Link the skill into `checkout` for every harness, and hide the links from the project's git + * through the repository's exclude file — a symlink at the checkout root would otherwise ride any + * sweeping `git add -A` onto the agent's branch. Best-effort: an entry already at a link's path is + * left alone, and a link that cannot be made is a worse run, not a failed one. + */ +export async function linkSkill( + repo: string, + checkout: string, + fs: LinkFs = nodeLinkFs(), + git: GitRunner = nodeGitRunner(), +): Promise { + for (const dir of HARNESS_SKILL_DIRS) { + const rel = `${dir}/${SKILL_NAME}` + await excludeFromGit(repo, `/${rel}`, undefined, git).catch(() => {}) + try { + const path = join(checkout, rel) + if (await fs.entryExists(path)) continue + await fs.mkdir(join(checkout, dir)) + await fs.symlinkDir(SKILL_DIR, path) + } catch { + // A filesystem that refuses the link: the agent still starts, without the skill. + } + } +}