diff --git a/packages/agent-data/DECISIONS.md b/packages/agent-data/DECISIONS.md index 01f40b214..d95edd25b 100644 --- a/packages/agent-data/DECISIONS.md +++ b/packages/agent-data/DECISIONS.md @@ -1,5 +1,6 @@ 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. +to the implementer's judgment. Flag conflicts instead of silently deviating. Keep +outdated decisions (no history). 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 @@ -10,22 +11,23 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. 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/` 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. -- 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. +- `.branches/` holds a project's persistent checkouts, one per branch at + `.branches/`: 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. +- `.branches/` is 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. +- Every git call through the package's own runner 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. + something pushes when there is a remote, 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 @@ -52,17 +54,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. 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. +- A write is a re-runnable function, not a finished commit: a push that fails, a lost race + or anything else, 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` 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 fails, a conflict or anything else, 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; 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: diff --git a/packages/agent-data/src/file-branch.SPEC.md b/packages/agent-data/src/file-branch.SPEC.md index 26ffcab3e..aa9b4c63c 100644 --- a/packages/agent-data/src/file-branch.SPEC.md +++ b/packages/agent-data/src/file-branch.SPEC.md @@ -20,7 +20,7 @@ A branch used as a file store: a branch of the project's repository holding file - **One branch, holding files nobody edits by hand** - the caller names it; nothing on it is anyone's working tree, so it can be pushed and pulled eagerly, which is what gives every machine and every cloud session the same view. - **Born parentless, or adopted from origin** - a branch origin already has is adopted; a branch that has to be created here starts from an empty commit with no parent, so no code commit is ever an ancestor of the file history. - **The persistent checkout, hidden from the project's git** - the branch is checked out at `.branches/`, named after its branch like every other checkout there, and that directory is hidden from the project's git. -- **One funnel, serialized per branch** - every write a long-lived process makes goes through one cycle — sync, run the operation, commit, push — and cycles for the same branch never interleave. +- **One funnel, serialized per branch** - every write a long-lived process makes goes through one cycle — sync, run the operation, commit, push — and cycles for the same branch never interleave. Reads do not wait for a cycle: a read during one may see the operation's files before they are committed, or none at all while a failed cycle is being put back. - **A write is an intent, not a commit** - when a push loses a race, the cycle syncs again and re-runs the operation against the fresher state instead of force-fitting a stale commit. - **A push is owed until it lands** - a commit that could not be pushed stays local, and the next cycle carries it out, even when that cycle writes nothing of its own. - **Conflicts resolve toward origin** - the checkout is nobody's working tree, so origin always wins and the local intent is re-applied on top. @@ -29,7 +29,7 @@ A branch used as a file store: a branch of the project's repository holding file - **A repository with no remote** - no `origin`, whatever other remotes it has: fine for a funneled write, an error for the pull, a refusal for a detached write. - **Reading from anywhere in the repository** - the persistent checkout when this location has one, else the branch's ref, else origin's copy of it, so a reader holds no copy of the files; a read can ask for a fresh copy instead. - **A reader that fetches once** - a reader opening many files fetches up front, picks one ref, and takes every read off it. -- **A detached one-shot write** - a command in any clone writes through a throwaway worktree on origin's tip and pushes straight to the branch, never touching the persistent checkout. +- **A detached one-shot write** - a command in any clone writes through a throwaway worktree on origin's tip and pushes straight to the branch, never touching the persistent checkout. On its way out it prunes the repository's stale worktree registrations, whatever branch they were for, as does the creation of the persistent checkout. - **The files an operation writes** - reading, writing, deleting and listing the files under the directory an operation is handed, with parent directories created on write. ## Business logic @@ -60,7 +60,7 @@ A long-lived process reads these files on every tick and writes them many times The branch is checked out at `.branches/` — under the project, named after its branch, exactly like the agent checkouts beside it. The directory is hidden from the project's git through the repository's own exclude file, so no sweeping `git add -A` can commit it onto a code branch and no tracked file changes to achieve that; this may well be the first checkout the project ever gets. -Making sure the branch and its checkout exist is idempotent and cheap once they do — one read of the checkout's branch. A registration left behind by a directory someone deleted by hand is pruned first, so it cannot block the checkout being made again. Nothing here fails outright: a project this cannot be set up in reports why and is otherwise left alone. +Making sure the branch and its checkout exist is idempotent and cheap once they do — one read of the checkout's branch. A registration left behind by a directory someone deleted by hand is pruned first, so it cannot block the checkout being made again; the prune covers the whole repository, so a stale registration of any other checkout goes with it. Nothing here fails outright: a project this cannot be set up in reports why and is otherwise left alone. ### One funnel, serialized per branch @@ -70,7 +70,7 @@ Two background jobs write the same files in the same moment. Neither may see, or #### Business logic -Every write a long-lived process makes goes through one cycle: make sure the branch and its checkout exist, sync with origin, run the operation against the checkout, commit whatever it changed, push. Cycles are serialized per repository and branch, so two of them can never interleave — the eager pull included, since it is the same cycle applying no change. +Every write a long-lived process makes goes through one cycle: make sure the branch and its checkout exist, sync with origin, run the operation against the checkout, commit whatever it changed, push. Cycles are serialized per repository and branch — the repository as the caller spells its path, so two spellings of one path are two independent chains — so two of them can never interleave — the eager pull included, since it is the same cycle applying no change. Reads are not part of the chain: a read during a cycle may see the operation's files before they are committed, or none at all while a failed cycle is being put back. A cycle never throws, because its callers run on background ticks with nobody to catch a failure. It reports one of three outcomes: it succeeded, saying whether anything changed and whether the push landed; or it failed with the change committed locally, meaning only the push failed and the next cycle will carry it; or it failed with nothing committed at all. @@ -84,7 +84,7 @@ Another machine pushes to the branch in the instant between this machine's sync #### Business logic -The change is expressed as an operation that can be run again, not as a fixed commit. When the push loses the race, the cycle syncs again and runs the operation against the fresher state rather than force-fitting a stale commit — the operation is the intent, the commit is only its serialization. It tries twice; a push that still fails, most likely for want of a network, keeps the commit locally and reports that it did, so the caller knows the change survived even though it is not yet shared. +The change is expressed as an operation that can be run again, not as a fixed commit. When the push is rejected, for a lost race or any other reason, the cycle syncs again and runs the operation against the fresher state rather than force-fitting a stale commit — the operation is the intent, the commit is only its serialization. It tries twice; a push that still fails, most likely for want of a network, keeps the commit locally and reports that it did, so the caller knows the change survived even though it is not yet shared. ### A push is owed until it lands @@ -96,7 +96,7 @@ Whenever the local branch holds commits origin does not, the cycle pushes — ev #### Business logic -Syncing fetches origin's copy of the branch and replays whatever local commits exist on top of it. When that cannot be done cleanly, the checkout is reset to origin's state outright. Nothing is lost by that, because the local intent is re-applied immediately afterwards by the cycle that is running — the whole design rests on the operation being re-runnable. +Syncing fetches origin's copy of the branch and replays whatever local commits exist on top of it. When that replay fails for any reason — a conflict, or a rebase that cannot start — the checkout is reset to origin's state outright. Nothing is lost by that, because the local intent is re-applied immediately afterwards by the cycle that is running — the whole design rests on the operation being re-runnable. ### A failed operation leaves nothing half-written @@ -154,9 +154,9 @@ An agent runs a command in its own checkout that has to add to these files. It m #### Business logic -A detached write fetches origin's tip of the branch, checks it out in a throwaway worktree outside the repository, runs the operation there, commits, pushes straight to the branch, and removes the worktree afterwards — registration and directory both, whether it succeeded or not. +A detached write fetches origin's tip of the branch, checks it out in a throwaway worktree outside the repository, runs the operation there, commits, pushes straight to the branch, and removes the worktree afterwards — registration and directory both, whether it succeeded or not, pruning the repository's stale worktree registrations with it, whatever branch they were for. -It follows the same intent rule as the funnel: a push that loses a race re-fetches, resets the worktree to origin's tip and runs the operation again, twice in all; a push that still fails is raised with git's own reason. A branch origin does not have yet is born by the write itself, parentless, exactly as it would be locally. An operation that writes nothing commits nothing and reports that it changed nothing. A repository with no remote is refused, and says so as its own outcome rather than as a failure. +It follows the same intent rule as the funnel: a push that loses a race re-fetches, resets the worktree to origin's tip and runs the operation again, twice in all; a push that still fails is raised with git's own reason, and so is anything else that fails — a worktree that cannot be made, a commit that fails, an operation that throws; only the missing remote is an outcome rather than a failure. A branch origin does not have yet is born by the write itself, parentless, exactly as it would be locally. An operation that writes nothing commits nothing and reports that it changed nothing. A repository with no remote is refused, and says so as its own outcome rather than as a failure. It never touches the persistent checkout, and it never moves the local branch ref: the machine running a long-lived process converges on its own next pull. diff --git a/packages/agent-data/src/git.SPEC.md b/packages/agent-data/src/git.SPEC.md index 2a072aeca..7a5e333ac 100644 --- a/packages/agent-data/src/git.SPEC.md +++ b/packages/agent-data/src/git.SPEC.md @@ -3,7 +3,7 @@ Running git on a caller's behalf: every command gets a time budget matched to it ## Business logic — TL;DR - **Every git command gets a time budget matched to its cost** - reads get the shortest budget, ordinary local mutations a longer one, and anything touching the network or writing a whole checkout the longest. -- **A timeout is not a git failure** - a command killed for outrunning its budget is reported as a timeout, recognisable across package boundaries, so a caller can clean up after an interrupted checkout creation without mistaking git's own refusals for one. +- **A timeout is not a git failure** - a command killed for outrunning its budget is reported as a timeout, recognisable across package boundaries, so a caller can clean up after an interrupted checkout creation without mistaking git's own refusals for one. A command whose output exceeds 16 MB is killed too, but reported as a plain failure, not a timeout. - **"Not a repo" is distinguishable from "git failed"** - a directory can be asked whether it sits inside a git working tree; anything unreadable reads as "not a repo". - **The checkout from anywhere inside** - from any directory in a repo, the root of the checkout it is in can be read. - **The line worth showing** - a failed invocation is reduced to git's own `fatal:` / `error:` / `remote:` line when there is one, else its first line. @@ -27,6 +27,8 @@ Each git invocation is classified by its subcommand, ignoring any leading global Removing or pruning worktrees counts as an ordinary local mutation; listing them counts as a read. +Every invocation's output is capped at 16 MB, enough for a large checkout's file listing; a command that exceeds it is killed and reported as a plain failure, never as a timeout. + #### Rationale A single flat budget, sized for reads, once covered every git call. That made the two slowest operations — creating a worktree and pushing — routinely die mid-flight on large repos, which is precisely the case where being killed is most destructive. diff --git a/packages/agent-data/src/index.SPEC.md b/packages/agent-data/src/index.SPEC.md index 3b5ccdc2f..a03e532de 100644 --- a/packages/agent-data/src/index.SPEC.md +++ b/packages/agent-data/src/index.SPEC.md @@ -1,4 +1,4 @@ -The package's main entry point: it gathers the two names, the git runner, the exclude rule and the branch used as a file store into one place for a caller to import. No business logic of its own. The two names are also reachable on their own (`names`), for code that runs in a browser and must not pull in git. +The package's main entry point: it gathers the two names, the git runner, the exclude rule and the branch used as a file store into one place for a caller to import. No business logic of its own. The git time budgets and the classification of a command stay inside the package: they are not exported. The two names are also reachable on their own (`names`), for code that runs in a browser and must not pull in git. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-branches/DECISIONS.md b/packages/skill-branches/DECISIONS.md index 1925933b7..e9de8af03 100644 --- a/packages/skill-branches/DECISIONS.md +++ b/packages/skill-branches/DECISIONS.md @@ -1,5 +1,6 @@ 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. +to the implementer's judgment. Flag conflicts instead of silently deviating. Keep +outdated decisions (no history). 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 @@ -15,25 +16,29 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. `.gitignore` is tracked. - A checkout starts as branch `agent-` in folder `.branches/agent-/`; `` 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 ` (`[a-z0-9-]+`) renames the - branch to `agent-`: 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 ` (`[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-`: 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/` 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. + `agent-data` aside, is the package's to remove, whatever the link's own name, and + whether or 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. + The package renames and deletes only `agent-*` branches. `attach` takes the caller's + branch as given, slash or not. - `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`. + `.branches/agent-data` by the program that keeps that branch checked out (a daemon), 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`. - 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. + push cannot land on someone else's branch. The branch the checkout carries right now, + suffix included, is not counted. - 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 @@ -41,9 +46,12 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. - 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). 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. + writes into the user's folder: a known limit). Every dependency folder down to two + levels under the root is linked, so a workspace package's own dependencies are there + too. Of the dot-entries only `.bin` is linked, so the agent runs the project's tools, as + one entry, so a bin an install adds lands in the user's folder too; 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. @@ -97,7 +105,8 @@ push. 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. + the command runs in, found from anywhere inside it; `status` also takes the path of a + checkout root. - `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 diff --git a/packages/skill-branches/src/cli.SPEC.md b/packages/skill-branches/src/cli.SPEC.md index 6178c7f60..e3af71d7e 100644 --- a/packages/skill-branches/src/cli.SPEC.md +++ b/packages/skill-branches/src/cli.SPEC.md @@ -30,9 +30,9 @@ See `## User story`. - `create [--base ]` - a checkout for the agent, on a fresh `agent-` branch from the stated base or the project's current head; `.branches/` is hidden from the project's git, the parent checkout's dependency directories are linked in and the `.branches/` links are refreshed. Reports the checkout's path and branch. - `attach ` - a checkout for a continued agent on an existing branch, with the same linking. An id that is not path-safe is refused by both, before git runs. - `name ` - renames the branch of the checkout the command runs in to `agent-`, refreshes the `.branches/` links, and reports the name the branch ended up with — suffixed when the wanted one was taken. Refused for a name that is not `[a-z0-9-]+`, and for a checkout on a branch the package did not mint, so the user's own branch is never renamed. The rules are the checkout lifecycle's (`worktree`). -- `status [path]` - the branch, whether the tree is clean, and whether the branch tip is on the remote, for the checkout the command runs in or the one at the stated path. A path that is not a checkout of its own is refused. +- `status [path]` - the branch, whether the tree is clean, and whether the branch tip is on the remote, for the checkout the command runs in or the one at the stated path. A path that is not a checkout of its own is refused. A checkout on no branch answers no branch and "not on the remote" without git being asked. - `list [--sizes]` - every agent checkout under `.branches/`: the agent id, the path, the branch it is on now, and, on request, its size on disk. -- `remove [--no-push]` - reclaims one agent's checkout under the reclaim rule (`reclaim`): a dirty tree is kept, the branch is pushed unless `--no-push`, the checkout goes only once the remote has it, and a branch that holds nothing goes with it. The `.branches/` links are refreshed afterwards, so a link named after a branch that just went is dropped. An id that is not path-safe is refused before anything is looked up; an id with no checkout is refused as such. +- `remove [--no-push]` - reclaims one agent's checkout under the reclaim rule (`reclaim`): a dirty tree is kept, the branch is pushed unless `--no-push`, the checkout goes only once the remote has it, and a branch that holds nothing goes with it. The branches it reports as deleted are the ones it tried to delete; a deletion git refused is not told apart. The `.branches/` links are refreshed afterwards, so a link named after a branch that just went is dropped. An id that is not path-safe is refused before anything is looked up; an id with no checkout is refused as such. - `prune [--no-push]` - `remove` for every checkout, with the links refreshed once at the end rather than after each; reports which were removed and, for each kept, the reason. Exits 0: a checkout kept under the rule is the rule working, not a refusal of the command. ### JSON out, a reason for a person, an exit code that says which diff --git a/packages/skill-branches/src/worktree-deps.SPEC.md b/packages/skill-branches/src/worktree-deps.SPEC.md index 045b0d019..53abc4cd1 100644 --- a/packages/skill-branches/src/worktree-deps.SPEC.md +++ b/packages/skill-branches/src/worktree-deps.SPEC.md @@ -39,7 +39,7 @@ See `## User story`: an agent's install must stay in the agent's checkout. #### Business logic -The worktree's dependency directory is a directory of its own, not a link to the parent's. When an agent installs in its worktree — which an agent that changes a dependency must — the package manager rewrites the entries of the worktree's directory and leaves the parent checkout's untouched. After the worktree is removed, the parent checkout's dependencies are exactly as they were. +The worktree's dependency directory is a directory of its own, not a link to the parent's. When an agent installs in its worktree — which an agent that changes a dependency must — the package manager rewrites the entries of the worktree's directory and leaves the parent checkout's untouched — except inside a scope directory or `.bin`, which are single linked entries, so a package added under a scope or a bin shim lands in the parent's copy. After the worktree is removed, the parent checkout's dependencies are exactly as they were. The package manager's private state — every dot-entry of a dependency directory except the executables directory — is not linked. The executables directory is, because an agent runs the project's tools. diff --git a/packages/skill-branches/src/worktree.SPEC.md b/packages/skill-branches/src/worktree.SPEC.md index 8ca7561a3..8e85c1a32 100644 --- a/packages/skill-branches/src/worktree.SPEC.md +++ b/packages/skill-branches/src/worktree.SPEC.md @@ -108,7 +108,7 @@ Teardown never leaves a stranded checkout behind, and never silently deletes som #### Business logic -Removal tolerates a path that is already gone or was never registered, so teardown can run more than once. Plain removal is attempted first, because git refuses it for a checkout it considers unclean — which, after the caller's clean check, means a state that was not anticipated. Removal is then retried forcefully so that, for example, an ignored build artifact cannot strand a checkout forever, but the forced removal is logged, because forcing past unknown state is exactly how uncommitted work was destroyed before. +Removal tolerates a path that is already gone or was never registered, so teardown can run more than once. Plain removal is attempted first, because git refuses it for a checkout it considers unclean — which, after the caller's clean check, means a state that was not anticipated. Removal is then retried forcefully so that, for example, an ignored build artifact cannot strand a checkout forever, but the forced removal is logged, because forcing past unknown state is exactly how uncommitted work was destroyed before. A forced removal that fails as well is not reported: the removal answers success with the checkout still on disk. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-tickets/DECISIONS.md b/packages/skill-tickets/DECISIONS.md index 3c39bf809..ec28e4693 100644 --- a/packages/skill-tickets/DECISIONS.md +++ b/packages/skill-tickets/DECISIONS.md @@ -1,5 +1,6 @@ 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. +to the implementer's judgment. Flag conflicts instead of silently deviating. Keep +outdated decisions (no history). 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 @@ -7,9 +8,9 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. ## The tickets - Two callers: the command an agent runs, and a long-lived program that keeps the branch - checked out, starts agents through the library, and imports issues with its own code, - stamping `meta.json`. The executable is `tickets`. The package ships `SKILL.md`, the - agent's instructions. + checked out, starts agents through this package's functions, and imports issues with its + own code, stamping `meta.json`. The executable is `tickets`. The package ships + `SKILL.md`, the agent's instructions. - A ticket is a markdown file in `tickets/`. Its plan and its claim sit beside it: `.plan.md` and `.lock.md`, `` the filename without `.md`. - Tickets live on `agent-data`, the branch `@gemstack/agent-data` names, never on a code @@ -23,7 +24,7 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. linking it stays until `queue done`. - A ticket's row, the same fields in `list` and `show`: the title from its `# ` line, the summary from the first prose line after `## TLDR`, else after the title, scanning past - headings to the end of the file, `Priority:` verbatim. + headings, `Priority:` lowercased. ## Flow: a claim - A claim is a committed file holding one line, `CLAIMED: `, so agents on other @@ -35,7 +36,7 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. - A lock is written only by a claim; someone else's `claim` is refused while it exists. `put` ignores it, so an import can refresh a ticket someone holds. - A claim the program committed but could not push still counts: the commit already guards - this machine's readers, and the gap is logged. A cycle that could not commit claims + this machine's readers, and the gap is logged. A write that could not commit claims nothing. The program's release is judged the same way: committed counts, pushed or not. A queue edit is not: it counts only once pushed. - Claiming a ticket you already hold succeeds and writes nothing, so a re-run does not @@ -70,8 +71,8 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. ## Flow: the command - Every command names a ticket by its bare filename or its `tickets/` path, so a queue entry's link target can be pasted in as is; a sibling's name (`.plan.md`, - `.lock.md`) is `invalid-path` to every command, `put` taking `.plan.md` the one - exception. + `.lock.md`) is `invalid-path` to every command, `put` taking `.plan.md` and `meta.json` + the exceptions. - No command reads `meta.json`: only the importing program does, for its one key `lastImportedAt`. - A read fetches origin once and reads everything from that copy (the library's queue read diff --git a/packages/skill-tickets/src/cli.SPEC.md b/packages/skill-tickets/src/cli.SPEC.md index d92abbe44..32e794cfd 100644 --- a/packages/skill-tickets/src/cli.SPEC.md +++ b/packages/skill-tickets/src/cli.SPEC.md @@ -37,7 +37,7 @@ See `## User story`. - `put ` - writes one file under `tickets/` from standard input: a ticket, its `.plan.md`, or `meta.json`, named bare or as its `tickets/…` path like every other command's argument. Anything else — a `.lock.md`, a path with segments, a non-markdown name — is refused. Claims are never written this way; they go through `claim`. - `close ` - removes a ticket together with its plan and its claim, because `tickets/` holds only open tickets. A ticket that is not there is refused; so is a ticket someone else holds, naming the holder — closing would take their claim with the ticket. - `claim ` - claims a ticket for the holder the working directory names, before planning or working it. A ticket that does not exist is refused; a ticket someone else holds is refused *and told who holds it* (when the lock names anyone readable), so the agent can back off and pick another. A claim naming this very holder again still counts as claimed, so an agent that re-runs the command after a lost race is not confused by its own lock. -- `release ` - lifts the caller's own claim. A ticket with no claim, and a claim belonging to someone else, are both refused — the second naming the holder it belongs to. +- `release ` - lifts the caller's own claim. A ticket with no claim, and a claim belonging to someone else, are both refused — the second naming the holder it belongs to. `release` never reads the ticket itself, so a name no ticket has is refused as unclaimed, not as missing. A ticket is named either by its bare filename or by its `tickets/` path; anything that is neither is refused before anything is read. @@ -83,7 +83,7 @@ An agent claims a ticket without ever having been told an identity. #### Business logic -`claim` and `release` name the holder the environment and the working directory say they are (`holder`): `AGENT_ID` when the process that started the agent set it, else the current branch name. A checkout on no branch is refused as `no-identity` — there is nothing to claim as. +`claim`, `release` and `close` name the holder the environment and the working directory say they are (`holder`): `AGENT_ID` when the process that started the agent set it, else the current branch name. A checkout on no branch is refused as `no-identity` — there is nothing to claim as, and `close` cannot tell its own claim from someone else's. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-tickets/src/names.SPEC.md b/packages/skill-tickets/src/names.SPEC.md index a6f92f1ac..a4f924901 100644 --- a/packages/skill-tickets/src/names.SPEC.md +++ b/packages/skill-tickets/src/names.SPEC.md @@ -67,7 +67,7 @@ Guessing a plausible number would hide the typo, and clamping would claim one of #### Business logic -A ticket may carry a `GitHub: [#42](…/issues/42)` header line. The issue it tracks is read from that line as a `#42` reference: the number comes from the URL when there is one — the label is display text, the URL is the identity — and from the label itself for a line written by hand with no URL. A ticket with no such line, or one whose line names no number, tracks no issue. +A ticket may carry a `GitHub: [#42](…/issues/42)` header line. The issue it tracks is read from that line as a `#42` reference: the number comes from the URL when there is one — an `…/issues/` or an `…/pull/` URL alike; the label is display text, the URL is the identity — and from the label itself for a line written by hand with no URL. A ticket with no such line, or one whose line names no number, tracks no issue. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-tickets/src/tickets.SPEC.md b/packages/skill-tickets/src/tickets.SPEC.md index 79ef78f45..34084fdda 100644 --- a/packages/skill-tickets/src/tickets.SPEC.md +++ b/packages/skill-tickets/src/tickets.SPEC.md @@ -24,9 +24,9 @@ See `## User story`. #### Business logic -A ticket lists as: its filename, which is also its identity; its title, from the `# ` heading; its summary, the first line under `## TLDR`, or the first prose line when the ticket has no TLDR, or empty when it has neither; its `Priority:` verbatim, as written; its `Topics:`, as bare tags with the list's cosmetic brackets stripped; and its `GitHub:` link split into the text a reader clicks and the URL it goes to. The three keys are optional, and are read only from the block *above* the title, so a key-looking line in the body is prose. Only the head of each ticket is read — nothing below it is shown in a list. +A ticket lists as: its filename, which is also its identity; its title, from the `# ` heading; its summary, the first line under `## TLDR`, or the first prose line when the ticket has no TLDR, or empty when it has neither, and never a line starting with `Source:` (imported tickets carry one as a trailer); its `Priority:` lowercased; its `Topics:`, as bare tags with the list's cosmetic brackets stripped; and its `GitHub:` link split into the text a reader clicks and the URL it goes to. The three keys are optional, and are read only from the block *above* the title, so a key-looking line in the body is prose. Only the head of each ticket is read — nothing below it is shown in a list: a list reads the first 4,000 bytes of each ticket, so a title or summary past that point is missing from its row, while one ticket in full is read whole, and its row can differ from the list's on such a ticket. -The tickets come back newest first. A `.plan.md` or `.lock.md` never becomes a row of its own. +The tickets come back newest first, and tickets of one day in filename order. A `.plan.md` or `.lock.md` never becomes a row of its own. ### A ticket's date @@ -50,7 +50,7 @@ A `.lock.md` beside a ticket marks it claimed, and the holder it names is report #### Business logic -Every part of the format is optional to the reader. A ticket with no heading is titled from its filename, made readable by decoding any escapes and turning underscores into spaces — which covers both `_.md` and a `-.md` name from an issue tracker. A key nobody recognises is preamble noise, not a field. Asking whether a folder holds any ticket at all is a listing, not a parse, for a caller that asks often. +Every part of the format is optional to the reader. A ticket with no heading is titled from its filename, made readable by decoding any escapes and turning underscores into spaces — which covers both `_.md` and a `-.md` name from an issue tracker. With no heading there is no key block: `Priority:`, `Topics:` and `GitHub:` go unread, and the first non-heading line, a key line included, becomes the summary. A key nobody recognises is preamble noise, not a field. Asking whether a folder holds any ticket at all is a listing, not a parse, for a caller that asks often. ### One ticket in full