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
77 changes: 35 additions & 42 deletions packages/agent-data/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,48 +1,43 @@
Non-obvious decisions only, grouped by business-logic flow. Anything not listed is left
to the implementer's judgment. Flag conflicts instead of silently deviating.

A bullet is a person's pick, and says what it was picked over. What the code does belongs
in SPEC.md; a choice made while implementing is the implementer's judgment, not a
decision. An AI proposes a bullet and asks; it never adds or rewrites one.

## The package
- A **library, not a skill**: no `SKILL.md`, no command. Skills import it; they never import
each other.
- Named for the `agent-data` branch, but no function hardcodes it: every one that touches a
branch takes it as an argument.
- A **library, not a skill**: no `SKILL.md`, no command. Skills import it; they never
import each other.
- Named for the `agent-data` branch, but no function hardcodes it: every one that touches
a branch takes it as an argument.
- `.branches/<branch>` holds a project's persistent checkouts, one per branch: the agents'
own (made by other packages) and the data branch's (made here). The directory name is
exported. Dotted so a `*` glob skips it: each checkout is a full copy, so a tool that
descends does N times the work.
- Hidden through the common git dir's `info/exclude`, never a committed `.gitignore`: the
library must not touch tracked files; a per-worktree `info/exclude` is never read, and
one line there, written once, covers every checkout. Best-effort: the checkout stands
even when the rule could not be written. Prune before adding: a hand-deleted checkout
leaves a registration that fails the add.
- Every git call has a time budget: a listed read 10s, network and `worktree add` 120s,
other writes 30s. `worktree` goes by its second word (`add` slow, `list` a read, the rest
a write), `branch` by its flags (bare or a listing flag reads; `-D`, `-m` or a new name
writes); an unlisted subcommand gets the write budget, so a write is never cut short at
the read budget. A killed `push` may have half landed, so a git call that outruns its
budget fails as a timeout, its own error kind, never as a plain failure. git's output
buffer is 16 MB (a large checkout's file listing); an overrun is not a timeout.
even when the rule could not be written.
- Every git call has a time budget; a call that outruns it fails as a timeout, its own
error kind, never as a plain failure: a killed `push` may have half landed.

## The branch
- A branch of the project's repository holds the agents' data (tickets, the queue) the way
`gh-pages` holds a site; code branches hold only code. Every write that changes something
pushes, and a pull is a cycle of its own, so a machine that writes nothing still gets what
the others pushed.
`gh-pages` holds a site; code branches hold only code. Every write that changes
something pushes, and a pull is a cycle of its own, so a machine that writes nothing
still gets what the others pushed.
- One branch for all skills, each with its own folder or file. Not one per skill: every
extra branch needs its own checkout and its own sync failure to report.
- Missing locally, it is adopted from origin's copy; missing there too, it is born an orphan
branch, so no code commit is ever in its history.
- Missing locally, it is adopted from origin's copy; missing there too, it is born an
orphan branch, so no code commit is ever in its history.
- The name is written once, in `names`, as `DATA_BRANCH`, and imported everywhere else.
`names` is its own entry point with no node imports, so browser code can import it.
- A read works from anywhere in the repository, an agent's worktree included: the checkout
is looked for beside the real `.git`. A file is looked for in that checkout when there is
one, then on the local branch, then on origin's copy; a directory listing always comes off
a ref. A read can ask for a fresh copy: fetch, then origin's ref instead of the checkout,
for a reader whose checkout may trail. A one-shot command opens the branch once: one fetch
when there is an origin, every read off origin's copy (the local branch when there is no
`origin/<branch>`), because its own writes go straight to the remote and never move the
local branch. A read never fails: a missing file, a missing branch and a git that could
not run all read as absent.
is looked for beside the real `.git`. A one-shot command opens the branch once: one
fetch when there is an origin, every read off origin's copy (the local branch when there
is no `origin/<branch>`), because its own writes go straight to the remote and never
move the local branch. A read never fails: a missing file, a missing branch and a git
that could not run all read as absent.

## Flow: a write
Fetch what others pushed → make the change → commit → push.
Expand All @@ -57,20 +52,18 @@ Fetch what others pushed → make the change → commit → push.
resets it, new files included. Both try the push twice; the command's write then throws
with nothing left to retry, the process's never throws: its callers are background
ticks.
- A write is a re-runnable function, not a finished commit: a lost race winds the attempt's
commit back and runs the function again on the new files, so the change lands once. The
message is the caller's: fixed, or a function run after the change, since a batch only
knows what it did once done; the library's own are `create the <branch> branch` for the
birth and `sync` for the pull. Never a force push. After two failed pushes the process's
write reports the failure and the commit stays local in its checkout; the next write or
pull rebases it onto the remote and pushes it with the new one. When that rebase conflicts
the checkout is reset to origin's tip: the remote wins, every unpushed commit is dropped
unreported, only the current change runs again.
- An op is handed a directory and writes into it. `BranchFileFs` is the file seam an op can
take instead of the disk, for tests (type and node implementation ship here, the op
injects it); it creates parent directories: git keeps no empty directory, so a skill's
folder vanishes with its last file and is absent on a new branch.
- A write is a re-runnable function, not a finished commit: a lost race winds the
attempt's commit back and runs the function again on the new files, so the change lands
once. The message is the caller's: fixed, or a function run after the change, since a
batch only knows what it did once done. Never a force push. After two failed pushes the
process's write reports the failure and the commit stays local in its checkout; the next
write or pull rebases it onto the remote and pushes it with the new one. When that
rebase conflicts the checkout is reset to origin's tip: the remote wins, every unpushed
commit is dropped unreported, only the current change runs again.
- An op is handed a directory and writes into it. Parent directories are created: git
keeps no empty directory, so a skill's folder vanishes with its last file and is absent
on a new branch.
- The remote is always `origin`; a repository without one is remote-less whatever other
remotes it has. Then the process's write commits locally and reports no error, a command's
write refuses (an outcome, not a throw), and the pull reports an error: nothing to
converge with.
remotes it has. Then the process's write commits locally and reports no error, a
command's write refuses (an outcome, not a throw), and the pull reports an error:
nothing to converge with.
93 changes: 30 additions & 63 deletions packages/skill-branches/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Non-obvious decisions only, grouped by business-logic flow. Anything not listed is left
to the implementer's judgment. Flag conflicts instead of silently deviating.

A bullet is a person's pick, and says what it was picked over. What the code does belongs
in SPEC.md; a choice made while implementing is the implementer's judgment, not a
decision. An AI proposes a bullet and asks; it never adds or rewrites one.

## The checkout
- One checkout per agent, a git worktree of the user's repository under `.branches/`,
branched from the project's head unless the caller names a base. Agents run in parallel,
Expand All @@ -11,54 +15,37 @@ to the implementer's judgment. Flag conflicts instead of silently deviating.
`.gitignore` is tracked.
- A checkout starts as branch `agent-<id>` in folder `.branches/agent-<id>/`; `<id>` comes
from the program that starts the agent, and must match `[A-Za-z0-9_-]+`, so no id can
build a path outside `.branches/`. `npx branches name <name>` (`[a-z0-9-]+`; the skill
asks for a leading letter or digit: at the command line a leading `-` reads as a flag, a
usage error, and the name check itself does not mind it) renames the branch to
`agent-<name>`: a rename, not a new branch, so nothing is left behind; the folder keeps
the id, since the agent is running inside it. A checkout on no branch is neither renamed
nor reclaimed; `status` answers it without a `branch`.
build a path outside `.branches/`. `npx branches name <name>` (`[a-z0-9-]+`) renames the
branch to `agent-<name>`: a rename, not a new branch, so nothing is left behind; the
folder keeps the id, since the agent is running inside it. A checkout on no branch is
neither renamed nor reclaimed; `status` answers it without a `branch`.
- After a checkout is made, named or removed, each checkout whose branch differs from its
folder name gets a sibling link `.branches/<branch>` to its folder, relative; a detached
checkout or a slashed branch gets none. A link whose target is an `agent-*` name,
`agent-data` aside, is the package's to remove, whatever it is called, and whether or
not the target exists; anything else at a link's path is left alone. `list` and `prune`
see directories only, so a link is never a checkout; a session name passes as an id, so
`remove <name>` follows the link `.branches/agent-<name>` to the checkout.
not the target exists; anything else at a link's path is left alone.
- No name the package mints holds a `/`: a folder and a link are named after a branch, and
a cloud session (a hosted agent run, started on a branch) cannot start on a slashed ref.
The package renames and deletes only `agent-*` branches.
- `agent-data` is `@gemstack/agent-data`'s data branch, checked out as
`.branches/agent-data` by the program that keeps it, not by this package. Never listed,
renamed or deleted; `data` is refused as an id, and an agent naming itself `data` gets
`agent-data-2`; `attach` guards the id only, never the branch it is given.
`agent-data-2`.
- A taken name gets `-2`, `-3`, … instead of a refusal: the agent asked for a name and
reads back the one it got. Taken means any local or remote-tracking branch, so the later
push cannot land on someone else's branch. The branch the checkout carries right now,
suffix included, is not counted: a checkout already on `agent-<name>-2` that asks for
`<name>` again keeps `-2` while `agent-<name>` is still taken, and takes `agent-<name>`
once it is free. Two agents naming the same thing at once race on the rename; the loser
takes the next free suffix, in at most three tries, then `git-failed`.
push cannot land on someone else's branch.
- Continuing an agent puts it back on the branch its work is on, even one the package did
not make; a branch gone locally comes back from origin's copy, and one gone everywhere
is recreated from the project's head: every branch the package deletes held nothing the
remote lacked.
- The user's installed dependencies are linked into the checkout, not copied or
reinstalled: one link per entry of the folder, absolute, so an install in the checkout
writes into the checkout (a scope like `@acme` is one entry, so a scoped install still
writes into the user's folder: a known limit). Every dependency folder down to two
levels under the root (not under `node_modules`, `dist`, `build`, `coverage` or a
dot-directory) is linked, so a workspace package's own dependencies are there too; a
tree already in the checkout is left alone. Of the dot-entries only `.bin` is linked, so
the agent runs the project's tools; the others (`.pnpm`, `.modules.yaml`) would tell the
package manager the checkout's tree was installed there, which it was not. The packages
still resolve: a link to a link resolves where the target lives.
writes into the user's folder: a known limit). Of the dot-entries only `.bin` is linked,
so the agent runs the project's tools; the others (`.pnpm`, `.modules.yaml`) would tell
the package manager the checkout's tree was installed there, which it was not.
- Everything after the worktree is best-effort: a checkout missing any of it is a worse
run, not a failed one.
- `create` or `attach` for an id that already has a checkout fails as `git-failed` with
git's own error, and so does `create` when the branch exists without one: `attach` is
the way then. `create` and `attach` each answer the path and the branch; `list` answers
one row per checkout in directory order: the id, the path, the branch and, asked for,
the size. Only a directory named `agent-<id>` counts as a checkout.

## Flow: reclaim
Deleting an agent's checkout to free disk, only after the remote has everything in it. It
Expand All @@ -69,28 +56,24 @@ push.
files included, is kept until a person commits or deletes it, and nothing of it is
pushed.
- An `agent-*` branch whose tip is reachable from another name's remote-tracking ref, on
any remote, holds nothing of its own (the holds-nothing rule): it goes with its checkout
unpushed, deleted with `-D`: git's own merged test asks the wrong question. Its own
copy, under its current name or its upstream's, does not count. Pushed means on
`origin`, the only remote the package pushes to. Both reads take the local
remote-tracking refs, never a fetch: the push that put a tip there wrote them.
any remote, holds nothing of its own: it goes with its checkout unpushed, deleted with
`-D`: git's own merged test asks the wrong question. Its own copy, under its current
name or its upstream's, does not count. Pushed means on `origin`, the only remote the
package pushes to. Both reads take the local remote-tracking refs, never a fetch: the
push that put a tip there wrote them.
- The caller may name a pushed commit through the library, not from the command line: the
commit a cloud session pushed on the agent's behalf. A checkout whose tip is an ancestor
of it goes without a push and keeps its branch, even one the holds-nothing rule would
delete.
of it goes without a push and keeps its branch, even one the rule above would delete.
- An agent that switched to another branch leaves `agent-<id>` behind; it goes with the
checkout once the branch the agent ended on contains it.
- A folder under `.branches/` that git no longer knows as a worktree is left alone, and
`list` still shows it, without a branch: a git command run inside it would act on the
user's own checkout.
- A removal git refuses as unclean after the clean check passed is forced, and says so on
stderr: an ignored build artifact must not strand a checkout for good.
- `remove` and `prune` push by default; `--no-push` opts out. `remove` of a missing
checkout is a refusal, `no-checkout`; a removal judges the birth branch before anything
goes, then removes the checkout, then the branches, since git will not delete a branch a
worktree has out. `remove` names the branches that went with it, absent when none.
`prune` lists only the ids it removed, and the checkouts it kept, each with its reason,
in its result, nothing on stderr but the forced-removal line, and exits 0.
- `remove` and `prune` push by default; `--no-push` opts out. A removal judges the birth
branch before anything goes, then removes the checkout, then the branches, since git
will not delete a branch a worktree has out.
- The package reads no configuration and never asks whether an agent still runs: the
caller says whether it may push, and may pass a hook that runs just before the checkout
goes, to stop whatever serves the tree; the command line passes no hook.
Expand All @@ -110,30 +93,15 @@ push.
refusal (a rule saying no) adds one line for a person on stderr and exits 1. A malformed
command line (an unknown flag, the wrong argument count) never gets that far: the usage
on stderr, nothing on stdout, exit 2. An id the charset rejects is a refusal,
`invalid-id`, not a usage error; one starting with `-` reads as a flag, a usage error,
unless the arguments follow `--`.
- A command that throws is reported like a refusal, reason `git-failed`, the error's own
line as `detail` on stdout and on stderr.
`invalid-id`, not a usage error. A command that throws is reported like a refusal,
reason `git-failed`, the error's own line as `detail` on stdout and on stderr.
- `create`, `attach`, `list`, `remove` and `prune` act on the project, found from the
`.branches/` layout even from inside a checkout; `name` and `status` act on the checkout
the command runs in, found from anywhere inside it. `status` also takes the path of a
checkout root. `status` answers the path, the branch, whether the tree is clean, and
whether the tip is on the remote.
- The keys: `agentId`, `path`, `branch`, `clean`, `onRemote`, `sizeBytes`, `detail`,
`removed`, `skipped` (each with `agentId`, `reason`, and the person's line as `detail`),
`branchesDeleted`.
the command runs in, found from anywhere inside it.
- `list` answers with a bare JSON array; every other result and every refusal is an object
whose `ok` tells the two apart.
- Outside a repository, a command that needs one refuses with `not-a-repo`: only git's own
"not a git repository" reads as that; every other failure stays `git-failed`. An id is
checked before the repository, a session name after it, and `status <path>` skips the
repository check, so outside one it answers `not-a-worktree`.
- A refusal names its subject: `invalid-id` and `no-checkout` the id, `status`'s
`not-a-worktree` the path, `dirty` and `not-on-remote` the branch, `not-on-remote` also
git's reason when a push was tried; `name`'s refusals, `not-a-repo`, and
`not-a-worktree` and `no-branch` from `remove`, carry the reason alone. The refusals:
`invalid-id`, `invalid-name`, `not-a-worktree`, `no-branch`, `not-an-agent-branch`,
`no-checkout`, `dirty`, `not-on-remote`, `not-a-repo`, `git-failed`.
"not a git repository" reads as that; every other failure stays `git-failed`.
- The skill tells the agent where it is: on `agent-*` the checkout is its whole workspace,
and the dependency files and skill folders in it are links to the user's copies, never
edited; on any other branch under `.branches/` it was continued on that branch on
Expand All @@ -144,7 +112,6 @@ push.
- Each agent tool (Claude Code, Codex) looks for skills in its own folder at the checkout
root: `.claude/skills`, `.agents/skills`. In every checkout it makes, the package links
its own folder, which holds `SKILL.md`, into both as `branches`, hidden through the
repository's exclude, whose entry also hides an untracked project file at that path. An
entry already there, a committed skill say, is left alone. A caller may name further
skills to link in beside it, each under its own name, not from the command line;
temporary, until the project commits its own skill files.
repository's exclude. An entry already there, a committed skill say, is left alone. A
caller may name further skills to link in beside it, each under its own name, not from
the command line; temporary, until the project commits its own skill files.
Loading
Loading