From fb6c9999a640ba60595a6c5f3d0f2dbcfde689db Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 29 Aug 2026 01:08:33 +0300 Subject: [PATCH] Third read: SPECs match the code, contract written out --- packages/agent-driver/SPEC.md | 1 + packages/agent-driver/src/actions.SPEC.md | 2 ++ packages/agent-driver/src/actions.ts | 9 +++++++-- packages/agent-driver/src/types.SPEC.md | 5 +++-- packages/agent-driver/src/types.ts | 9 ++++----- packages/skill-branches/SPEC.md | 2 +- packages/skill-branches/src/branch-links.SPEC.md | 2 +- packages/skill-branches/src/branch-links.test.SPEC.md | 2 +- packages/skill-branches/src/branch-links.ts | 2 +- packages/skill-branches/src/cli.SPEC.md | 2 +- packages/skill-branches/src/git.test.SPEC.md | 2 +- packages/skill-branches/src/git.test.ts | 6 +++--- packages/skill-branches/src/reclaim.SPEC.md | 4 ++-- packages/skill-branches/src/reclaim.test.ts | 8 ++++---- packages/skill-branches/src/reclaim.ts | 8 ++++---- packages/skill-branches/src/worktree.SPEC.md | 4 ++-- packages/skill-branches/src/worktree.test.ts | 3 +-- packages/skill-branches/src/worktree.ts | 10 +++++----- 18 files changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/agent-driver/SPEC.md b/packages/agent-driver/SPEC.md index f44aecae..de742b20 100644 --- a/packages/agent-driver/SPEC.md +++ b/packages/agent-driver/SPEC.md @@ -13,6 +13,7 @@ The npm package `agent-driver`: a coding-agent CLI wrapped as a black box, for a - **caller** — the product that embeds the package and drives agents through it. - **turn** — one prompt to the wrapped CLI: the CLI's own loop runs to completion, and the turn resolves with the CLI's final message, plus the CLI's session id and the turn's usage when the CLI reports them. - **framing** — the role text a driver delivers as the wrapped CLI's system prompt: fixed for the agent when the driver starts, optionally extended per turn. +- **hand-off** — the caller giving a whole task to a coding-agent session that runs and pushes on its own, such as a Claude Code cloud session. Its **anchor** is the commit that session pushed; the caller recognises the session's branch afterwards by that commit's ancestry. ## Business logic — TL;DR diff --git a/packages/agent-driver/src/actions.SPEC.md b/packages/agent-driver/src/actions.SPEC.md index 92bd13a7..8077bd05 100644 --- a/packages/agent-driver/src/actions.SPEC.md +++ b/packages/agent-driver/src/actions.SPEC.md @@ -26,6 +26,8 @@ See `## User story`. Each prompt dispatches the workflow the caller named when it configured the driver — one that echoes the correlation id into its run name and uploads the transcript — with the prompt text, then polls until that run completes. A run that concludes as anything other than success fails the turn and reports the run's URL. The wait gives up after an hour by default — GitHub's own job cap is six — and the user pressing Stop, at either the agent level or for the single turn, ends the wait immediately. +What the workflow must do for the driver to find its run and read its result: accept the inputs `prompt`, `correlation_id` and `branch`, and the optional `model` and `resume_session_id`; put the correlation id in the run's display name; push the agent's work to the branch it was given; and upload one artifact whose name contains the correlation id, holding `execution.json` — the CLI's transcript, a JSON array of the same messages it prints one per line locally — and `meta.json` with the branch it pushed, as `{ "branch": "…" }`. A run that uploads no artifact, or an artifact without `execution.json`, fails the turn. + The system prompt framing is prepended to the prompt text rather than passed as a separate input. #### Rationale diff --git a/packages/agent-driver/src/actions.ts b/packages/agent-driver/src/actions.ts index d41882ff..980e957d 100644 --- a/packages/agent-driver/src/actions.ts +++ b/packages/agent-driver/src/actions.ts @@ -56,7 +56,12 @@ export interface ActionsDriverOptions { * in its `allowed_bots`. */ token: string - /** Workflow file to dispatch, e.g. `"agent.yml"`; it must echo the correlation id and upload the transcript. */ + /** + * Workflow file to dispatch, e.g. `"agent.yml"`. Its contract: inputs `prompt`, `correlation_id`, + * `branch`, and optionally `model` and `resume_session_id`; the correlation id in the run's + * display name; the work pushed to `branch`; one artifact named after the correlation id holding + * `execution.json` (the CLI's transcript as a JSON array) and `meta.json` (`{ "branch": "…" }`). + */ workflow: string /** Git ref the first turn runs on. Later turns follow the branch the agent pushed. */ ref?: string @@ -220,7 +225,7 @@ export class ActionsSession implements DriverSession { private async readRunArtifact(runId: number, correlationId: string): Promise<{ execution: string; branch?: string }> { const list = await this.api<{ artifacts?: { id: number; name: string }[] }>(`/repos/${this.owner}/actions/runs/${runId}/artifacts`) const artifact = (list.artifacts ?? []).find(a => a.name.includes(correlationId)) ?? list.artifacts?.[0] - if (!artifact) throw new Error(`Run ${runId} uploaded no artifact; the workflow's collect step did not run.`) + if (!artifact) throw new Error(`Run ${runId} uploaded no artifact; the workflow must upload the transcript as one.`) const res = await this.request(`/repos/${this.owner}/actions/artifacts/${artifact.id}/zip`) const entries = readZip(Buffer.from(await res.arrayBuffer())) diff --git a/packages/agent-driver/src/types.SPEC.md b/packages/agent-driver/src/types.SPEC.md index 19b03d98..51cee357 100644 --- a/packages/agent-driver/src/types.SPEC.md +++ b/packages/agent-driver/src/types.SPEC.md @@ -8,6 +8,7 @@ The user picks which coding agent does the work — Claude Code or Codex — and - **caller** - the product that embeds the package and drives agents through it. - **turn** - one prompt handed to the wrapped CLI and the whole loop it runs in response, ending with the agent's final message. A turn is the unit the caller gates on. +- **hand-off** - the caller giving a whole task to a coding-agent session that runs and pushes on its own, such as a Claude Code cloud session; its **anchor** is the commit that session pushed, by whose ancestry the caller recognises the session's branch afterwards. - **quota window** - one named allowance the account's subscription is measured in: the current session, the current week across all models, or the current week for one model. ## Business logic — TL;DR @@ -58,7 +59,7 @@ The caller's UI shows a quota bar for a Claude Code agent and none for a Codex o #### Business logic -Three capabilities are optional. Reading where the account's subscription quota stands belongs to the driver rather than to any one agent, because it is an account-wide fact — and a coding agent that cannot report it offers nothing rather than a guess, which is exactly what makes a caller's spending limits inapplicable rather than wrongly applied. Reading a file out of the agent's workspace is offered only when that workspace is on this machine. Continuing a previous conversation is best-effort: a driver that cannot resume runs a fresh prompt instead, which is the ordinary case and never an error. +Three capabilities are optional. Reading where the account's subscription quota stands belongs to the driver rather than to any one agent, because it is an account-wide fact — and a coding agent that cannot report it offers nothing rather than a guess, which is exactly what makes a caller's spending limits inapplicable rather than wrongly applied. Reading a file out of the agent's workspace is offered only by a driver that can still reach that workspace — from disk, or from the branch a runner pushed; a driver whose work lives where it cannot read offers nothing. Continuing a previous conversation is best-effort: a driver that cannot resume runs a fresh prompt instead, which is the ordinary case and never an error. ### Events are for looking, not for deciding @@ -94,7 +95,7 @@ The price is notional in any case: under a subscription the user pays a flat fee #### Rationale -There are deliberately no daily allowances. The subscription is measured in a several-hour session window and in weeks, with individual models getting weeks of their own; nothing is measured per day, and an earlier plan for a daily limit was written against an allowance that does not exist. +There are deliberately no daily allowances. The subscription is measured in a several-hour session window and in weeks, with individual models getting weeks of their own; nothing is measured per day. ### "We could not ask" is never "nothing is used", and a failure says whether to ask again diff --git a/packages/agent-driver/src/types.ts b/packages/agent-driver/src/types.ts index ed747dc4..b33ba860 100644 --- a/packages/agent-driver/src/types.ts +++ b/packages/agent-driver/src/types.ts @@ -28,8 +28,8 @@ export type DriverImplId = 'claude-code' | 'claude-web' | 'github-actions' | 'co export interface Driver { /** * Stable id for this concrete implementation, e.g. `"claude-code"`. Not the driver's *name* - * (`claude`), which is the user's choice: one driver has an implementation per place it can run - * (D5); the caller maps it back to the choice. + * (`claude`), which is the user's choice: one driver has an implementation per place it can run — + * the caller maps it back to the choice. */ readonly id: DriverImplId /** Boot a session bound to a workspace directory. */ @@ -76,7 +76,7 @@ export interface DriverStartOptions { /** A booted agent session, bound to one workspace. */ export interface DriverSession { - /** Stable id (the agent's own session id when it exposes one). */ + /** Stable id the driver mints for this session. The wrapped CLI's own session id, when it has one, is {@link DriverTurn.sessionId}. */ readonly id: string /** Absolute workspace path the agent is bound to. */ readonly cwd: string @@ -194,8 +194,7 @@ export interface DriverQuotaWindow { * `week-model` a single model's week (Opus/Sonnet get their own). * * Note there is deliberately no `day`: Claude measures a 5-hour session and a - * week, and nothing per day (#519 was specced against a daily limit that does - * not exist). + * week, and nothing per day. */ kind: 'session' | 'week' | 'week-model' | 'unknown' /** How much of the window is gone, 0-100. */ diff --git a/packages/skill-branches/SPEC.md b/packages/skill-branches/SPEC.md index 2c7bc1fa..77522198 100644 --- a/packages/skill-branches/SPEC.md +++ b/packages/skill-branches/SPEC.md @@ -1,6 +1,6 @@ Branch management for coding agents, as an npm package: one git checkout per agent under a project's `.branches/`, named as its branch; the parent checkout's dependencies shared into it; a navigable link per branch name; one retention rule under which a checkout is reclaimed — only once everything it holds is on the remote; and the instructions an agent follows to live inside that rule (`SKILL.md`). -The package knows git and the filesystem, nothing else. The same functions serve every caller: a daemon that starts agents, a dashboard that lists and reclaims checkouts, and an agent's own shell, through the `branches` command a daemon puts on the PATH of every agent it starts on its machine. What a caller knows beyond git — whether an agent is still running, whether its handoff allows a push, what a cloud hand-off already pushed — is passed in; the package never reads an agent's record. +The package knows git and the filesystem, nothing else. The same functions serve every caller: a daemon that starts agents, a dashboard that lists and reclaims checkouts, and an agent's own shell, through the `branches` command a daemon puts on the PATH of every agent it starts on its machine. What a caller knows beyond git — whether an agent is still running, whether the caller allows a push, which pushed commit already holds its work — is passed in; the package never reads an agent's record. ## Business logic — TL;DR diff --git a/packages/skill-branches/src/branch-links.SPEC.md b/packages/skill-branches/src/branch-links.SPEC.md index fede5c48..7a6c39af 100644 --- a/packages/skill-branches/src/branch-links.SPEC.md +++ b/packages/skill-branches/src/branch-links.SPEC.md @@ -2,7 +2,7 @@ Keeps a project's `.branches/` directory navigable by branch name; a daemon runs ## Business logic — TL;DR -- **Reconcile, don't track** - each pass derives the wanted links from the checkouts actually on disk (one link per worktree whose current branch differs from its directory name), creates what is missing, and drops the package's own links that are stale — no longer wanted, or now belonging to a newer checkout that reuses the name. A detached worktree, or one on a legacy slash-named branch, gets no link. +- **Reconcile, don't track** - each pass derives the wanted links from the checkouts actually on disk (one link per worktree whose current branch differs from its directory name), creates what is missing, and drops the package's own links that are stale — no longer wanted, or now belonging to a newer checkout that reuses the name. A detached worktree, or one on a slash-named branch, gets no link. - **A directory that is not a checkout has no branch** - the branch behind each link is read in the form that answers only for a directory git knows as a checkout of its own. A leftover directory under `.branches/` therefore gets no link at all, where a plain read would have answered with the *enclosing* repository's branch and produced a link named after the user's own branch, sitting among the agents' ones. - **Touch only what is provably ours** - a link is created, replaced, or removed only when it points (or would point) at a sibling checkout directory; a user's own file, directory, or foreign symlink at the same path is left alone, and nothing is ever created over it. The pass never throws. diff --git a/packages/skill-branches/src/branch-links.test.SPEC.md b/packages/skill-branches/src/branch-links.test.SPEC.md index c220acfe..3bfa249d 100644 --- a/packages/skill-branches/src/branch-links.test.SPEC.md +++ b/packages/skill-branches/src/branch-links.test.SPEC.md @@ -1,4 +1,4 @@ -What the tests cover: a checkout still on its birth branch gets no link, since the directory already carries the name; a renamed branch gets a sibling link and the stale name is dropped in the same pass; a reclaimed checkout loses its link, and detached or legacy slash-named branches never get one; against a real repository, a leftover `.branches/` directory that is not a checkout gets no link — where a plain branch read would have named it after the user's own branch — while a genuinely renamed checkout beside it still gets its link; user files and foreign symlinks are never removed, and nothing is created over a user's entry that occupies a wanted name. +What the tests cover: a checkout still on its birth branch gets no link, since the directory already carries the name; a renamed branch gets a sibling link and the stale name is dropped in the same pass; a reclaimed checkout loses its link, and detached or slash-named branches never get one; against a real repository, a leftover `.branches/` directory that is not a checkout gets no link — where a plain branch read would have named it after the user's own branch — while a genuinely renamed checkout beside it still gets its link; user files and foreign symlinks are never removed, and nothing is created over a user's entry that occupies a wanted name. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-branches/src/branch-links.ts b/packages/skill-branches/src/branch-links.ts index 656ff8da..32018c36 100644 --- a/packages/skill-branches/src/branch-links.ts +++ b/packages/skill-branches/src/branch-links.ts @@ -74,7 +74,7 @@ export async function reconcileBranchLinks(cwd: string, deps: BranchLinksDeps = const wanted = new Map() for (const entry of await worktrees(cwd).catch((): WorktreeDirEntry[] => [])) { const branch = await branchOf(entry.path).catch(() => undefined) - // A detached worktree has no name to link; a slashed name (a pre-#1581 branch) cannot be a + // A detached worktree has no name to link; a slashed name cannot be a // link name at all — both simply get no link. if (!branch || branch.includes('/')) continue // The link is a sibling of the checkout. A dir already carrying the branch's name needs none. diff --git a/packages/skill-branches/src/cli.SPEC.md b/packages/skill-branches/src/cli.SPEC.md index 1396bdd0..6178c7f6 100644 --- a/packages/skill-branches/src/cli.SPEC.md +++ b/packages/skill-branches/src/cli.SPEC.md @@ -1,4 +1,4 @@ -The package's command line: the same operations a daemon calls, for an agent (or a person) in a shell inside a project — so an agent names its session, checks its tree and reclaims checkouts through the one implementation the daemon uses, and a second surface is never a second behaviour. +The package's command line: the same operations a daemon calls, for an agent (or a person) in a shell inside a project — so an agent names its session, checks its tree and reclaims checkouts through the one implementation a daemon uses, and a second surface is never a second behaviour. ## User story diff --git a/packages/skill-branches/src/git.test.SPEC.md b/packages/skill-branches/src/git.test.SPEC.md index 5c5cb5a5..9ee0834f 100644 --- a/packages/skill-branches/src/git.test.SPEC.md +++ b/packages/skill-branches/src/git.test.SPEC.md @@ -1,6 +1,6 @@ What the tests cover: -- **Git time budgets** - each real git command the package runs is checked against the budget it should get: network operations (push, clone, fetch, pull) and creating a worktree get the longest budget; local mutations (staging, committing, initializing, checking out a path, removing or pruning worktrees) get the intermediate one; pure reads (listing files, status, revision and history queries, diffs, showing a blob, remote and ref lookups, ref enumeration, branch and worktree listings) get the shortest. An unrecognized subcommand, and an empty command, are treated as local mutations rather than as network operations. +- **Git time budgets** - every git command a caller may run through the runner — the package's own and a caller's — is checked against the budget it should get: network operations (push, clone, fetch, pull) and creating a worktree get the longest budget; local mutations (staging, committing, initializing, checking out a path, removing or pruning worktrees) get the intermediate one; pure reads (listing files, status, revision and history queries, diffs, showing a blob, remote and ref lookups, ref enumeration, branch and worktree listings) get the shortest. An unrecognized subcommand, and an empty command, are treated as local mutations rather than as network operations. - **The budgets stay distinct** - the three budgets are pinned to different values, and a network operation is verified to get far more time than a read, so nobody can widen reads to accommodate a slow operation and let a hung read hold a daemon. - **Pushing** - a failed push comes back as an error carrying git's own reason rather than the command echoed back; a push that times out says it timed out instead of reading like a rejected push; a timeout is told apart from a git rejection; and the reason shown for a failure is git's own `fatal:` line, not the exec preamble, else the message as given. - **Classification survives command options** - leading global options, options whose value is the following word, and options carrying their value inline all leave the real subcommand — and, for worktree commands, its distinction between creating and listing — correctly identified. diff --git a/packages/skill-branches/src/git.test.ts b/packages/skill-branches/src/git.test.ts index a32f6747..af22e0fe 100644 --- a/packages/skill-branches/src/git.test.ts +++ b/packages/skill-branches/src/git.test.ts @@ -3,8 +3,8 @@ import { test } from 'node:test' import { GIT_READ_TIMEOUT_MS, GIT_WRITE_TIMEOUT_MS, GIT_SLOW_TIMEOUT_MS, gitTimeoutMs, gitReason, pushBranch, GitTimeoutError, isGitTimeout, type GitRunner } from './git.js' /** - * Every git invocation in the package, taken from the call sites listed in #997, against the - * budget it should get. The point of the split is that these are not all the same number. + * Git invocations a caller may run through the runner — the package's own and a caller's (#997) — + * against the budget each should get. The point of the split is that these are not all the same number. */ const BUDGETS: { args: string[]; ms: number }[] = [ // The network and a whole checkout: the two the flat 10s budget was killing. @@ -36,7 +36,7 @@ const BUDGETS: { args: string[]; ms: number }[] = [ { args: ['branch'], ms: GIT_READ_TIMEOUT_MS }, { args: ['branch', '-D', 'agent-1'], ms: GIT_WRITE_TIMEOUT_MS }, { args: ['branch', '-m', 'agent-1', 'agent-cool'], ms: GIT_WRITE_TIMEOUT_MS }, - { args: ['branch', 'agent-data', 'abc123'], ms: GIT_WRITE_TIMEOUT_MS }, + { args: ['branch', 'topic', 'abc123'], ms: GIT_WRITE_TIMEOUT_MS }, { args: ['show-ref', '--verify', '--quiet', 'refs/heads/agent-x'], ms: GIT_READ_TIMEOUT_MS }, { args: ['for-each-ref', '--format=%(refname)', 'refs/heads/', 'refs/remotes/'], ms: GIT_READ_TIMEOUT_MS }, { args: ['worktree', 'list', '--porcelain'], ms: GIT_READ_TIMEOUT_MS }, diff --git a/packages/skill-branches/src/reclaim.SPEC.md b/packages/skill-branches/src/reclaim.SPEC.md index 6ad7a739..284926a7 100644 --- a/packages/skill-branches/src/reclaim.SPEC.md +++ b/packages/skill-branches/src/reclaim.SPEC.md @@ -9,7 +9,7 @@ Reclaiming an agent's checkout: the one implementation behind every surface that ## Glossary - **birth branch** - the `agent-` branch a checkout is created on, before the agent has named its session. -- **held by** - a commit the remote already has that provably contains everything a checkout could hold, such as the commit a cloud hand-off pushed. +- **held by** - a commit the remote already has that provably contains everything a checkout could hold, such as the commit a cloud session pushed on the agent's behalf. ## Business logic — TL;DR @@ -48,7 +48,7 @@ The user's remote never fills up with empty branches, one per task they handed t #### Business logic -When the caller names a commit the remote already has that holds everything the checkout could — a cloud hand-off's anchor — and the tree is clean with its tip inside that commit, the checkout is removed without any push and its branch stays. Anything short of that proof falls back to the ordinary rule. +When the caller names a commit the remote already has that holds everything the checkout could — the commit a cloud session pushed on the agent's behalf, say — and the tree is clean with its tip inside that commit, the checkout is removed without any push and its branch stays. Anything short of that proof falls back to the ordinary rule. ### A branch that holds nothing goes with the checkout diff --git a/packages/skill-branches/src/reclaim.test.ts b/packages/skill-branches/src/reclaim.test.ts index f1fea1b8..b97c7173 100644 --- a/packages/skill-branches/src/reclaim.test.ts +++ b/packages/skill-branches/src/reclaim.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises' import { addWorktree, agentBranchName, nodeGitRunner, reclaimWorktree, type ReclaimOptions } from './index.js' -// #982/E5: one rule decides every removal — the checkout goes only once the remote has it. So +// #982: one rule decides every removal — the checkout goes only once the remote has it. So // nothing local is ever the last copy of anything, and the one failure mode is legible: the push // did not land. Against real git, because "was the diff actually destroyed" is not a question a // fake answers. @@ -67,7 +67,7 @@ test('a checkout holding uncommitted work is kept — nothing is committed for t } }) -test('a reclaimed checkout keeps the work its agent committed, on the branch and the remote (#982/E5)', async () => { +test('a reclaimed checkout keeps the work its agent committed, on the branch and the remote (#982)', async () => { const { repo, path, branch } = await repoWithDirtyWorktree() const git = nodeGitRunner() try { @@ -81,7 +81,7 @@ test('a reclaimed checkout keeps the work its agent committed, on the branch and } }) -test('a checkout whose branch cannot reach the remote is kept, with what git said (E5)', async () => { +test('a checkout whose branch cannot reach the remote is kept, with what git said', async () => { // No remote configured: nothing is recoverable, so nothing is deleted. const { repo, path, branch } = await repoWithDirtyWorktree({ remote: false }) try { @@ -98,7 +98,7 @@ test('a checkout whose branch cannot reach the remote is kept, with what git sai } }) -test('a checkout that may not be pushed goes only from a clean tree on a tip the remote already has (B5)', async () => { +test('a checkout that may not be pushed goes only from a clean tree on a tip the remote already has', async () => { const { repo, path, branch } = await repoWithDirtyWorktree() const git = nodeGitRunner() try { diff --git a/packages/skill-branches/src/reclaim.ts b/packages/skill-branches/src/reclaim.ts index 28283245..acc5c823 100644 --- a/packages/skill-branches/src/reclaim.ts +++ b/packages/skill-branches/src/reclaim.ts @@ -11,7 +11,7 @@ import { } from './worktree.js' /** - * Reclaiming a checkout (#752/#737/E5): the one implementation behind every surface that removes + * Reclaiming a checkout (#752/#737): the one implementation behind every surface that removes * one — a daemon's sweep and teardown, a dashboard's Remove button, an agent's own CLI. * * **One rule: only what is on the remote may go.** The checkout is removed only once the remote @@ -33,12 +33,12 @@ export interface ReclaimOptions { birthBranch?: string /** * Whether the branch may be pushed to satisfy the rule. When not, only a clean tree on a tip the - * remote already has goes — removing what the remote holds publishes nothing (B5/#1379). + * remote already has goes — removing what the remote holds publishes nothing (#1379). */ mayPush: boolean /** - * A commit the remote already has that provably holds everything this checkout could — a cloud - * hand-off anchor (#1601). A clean tree whose tip is inside it goes without a push, and keeps + * A commit the remote already has that provably holds everything this checkout could — the commit a + * cloud session pushed on the agent's behalf, say (#1601). A clean tree whose tip is inside it goes without a push, and keeps * its branch. Anything short of that proof falls back to the ordinary rule. */ heldBy?: string diff --git a/packages/skill-branches/src/worktree.SPEC.md b/packages/skill-branches/src/worktree.SPEC.md index f3bf1536..e7bd49b3 100644 --- a/packages/skill-branches/src/worktree.SPEC.md +++ b/packages/skill-branches/src/worktree.SPEC.md @@ -36,7 +36,7 @@ Creating a checkout for a new agent creates its branch at the same time, from a Everything the repo has registered as a checkout can be listed, the main checkout included, together with the commit and branch each has. A project that is not a repo, or a git failure, yields an empty list, so a reconciliation scan never breaks. Administrative leftovers from checkout directories a crash removed can be pruned; pruning never touches a live checkout. -A checkout's size on disk can be read, best-effort. It only ever labels a "remove this" button, so a number that cannot be read — including on a platform without the tool that measures it — is simply unknown, which costs nothing, while a failure or a hang would cost the whole panel it sits in. The measurement does not follow the linked dependency trees, so an agent's checkout is not reported as the size of the whole dependency store. +A checkout's size on disk can be read, best-effort. It only ever labels a "remove this" button, so a number that cannot be read — including on a platform without the tool that measures it — is simply unknown, which costs nothing, while a failure or a hang would cost the whole listing it sits in. The measurement does not follow the linked dependency trees, so an agent's checkout is not reported as the size of the whole dependency store. ### A checkout's own directory, told apart from the repository around it @@ -52,7 +52,7 @@ Reading which branch is checked out therefore comes in two forms. The plain read #### Rationale -Git answers for any directory *inside* a repository, so a leftover directory under `.branches/` makes every command run in it act on the enclosing repository: the user's own checkout, on the user's own branch. Before this, such a directory had the sweep read an agent's branch as the user's `main`, try to commit the user's working tree and push the user's `main`, and had the rename links gain a link named after the user's own branch. +Git answers for any directory *inside* a repository, so a leftover directory under `.branches/` makes every command run in it act on the enclosing repository: the user's own checkout, on the user's own branch. Before this, such a directory had a reclaim pass read an agent's branch as the user's `main`, try to commit the user's working tree and push the user's `main`, and had the rename links gain a link named after the user's own branch. ### The branch is renamed once the agent names itself diff --git a/packages/skill-branches/src/worktree.test.ts b/packages/skill-branches/src/worktree.test.ts index 3d852df1..c007f861 100644 --- a/packages/skill-branches/src/worktree.test.ts +++ b/packages/skill-branches/src/worktree.test.ts @@ -113,8 +113,7 @@ test('pruneWorktrees runs `worktree prune` and tolerates failure', async () => { test('add/list/remove round-trips against a real git repo', async () => { const git = nodeGitRunner() // realpath so the mkdtemp path matches what `git worktree list` reports: on - // macOS tmpdir is under the /var -> /private/var symlink (same gotcha as - // enumerateGitRepos in install.ts). + // macOS tmpdir is under the /var -> /private/var symlink. const repo = await realpath(await mkdtemp(join(tmpdir(), 'worktree-'))) try { await git(['init'], repo) diff --git a/packages/skill-branches/src/worktree.ts b/packages/skill-branches/src/worktree.ts index 84898041..8ceec14f 100644 --- a/packages/skill-branches/src/worktree.ts +++ b/packages/skill-branches/src/worktree.ts @@ -167,7 +167,7 @@ export function parseWorktreeList(porcelain: string): WorktreeInfo[] { /** * Remove an agent's worktree. Tolerant of an already-gone / never-registered path so - * teardown stays idempotent (the agent child is detached; the daemon only holds its pid). + * teardown stays idempotent (a caller may run it twice). * * Plain removal first: it refuses a checkout git considers unclean, which after the * caller's {@link worktreeClean} check means a state we did not anticipate. Falling back to @@ -362,7 +362,7 @@ export function nodeSizeRunner(): SizeRunner { /** * A worktree's size on disk in bytes, or undefined when it cannot be read (#798). Best-effort by * design: this only ever labels a "remove this" button, so a missing number costs nothing while a - * throw or a hang would cost the panel it sits in. `du` is absent on Windows, which reads as + * throw or a hang would cost the listing it sits in. `du` is absent on Windows, which reads as * unknown like any other failure. */ export async function worktreeSize(path: string, size: SizeRunner = nodeSizeRunner()): Promise { @@ -375,7 +375,7 @@ export async function worktreeSize(path: string, size: SizeRunner = nodeSizeRunn } /** - * Whether a branch is on the remote, with the local tip already there (E5). + * Whether a branch is on the remote, with the local tip already there. * * The one predicate the whole retention story is built on: nothing local is ever the last copy of * work, so anything the remote has may be deleted and anything it does not have stays. It replaced @@ -419,10 +419,10 @@ export async function worktreeClean(path: string, git: GitRunner = nodeGitRunner } /** - * Whether the repo has any remote configured at all. What the sweep asks once per project: with + * Whether the repo has any remote configured at all. What a caller asks once per project before reclaiming its checkouts: with * no remote, {@link branchPushed} is false for every checkout and the push cannot land, so the * whole per-checkout probe-and-push cycle is doomed before it starts — and that answer cannot - * change between two rows of the same sweep. Anything unreadable answers `false`, like + * change between two checkouts of the same pass. Anything unreadable answers `false`, like * {@link branchPushed}: keeping a checkout is the safe direction. */ export async function repoHasRemote(repo: string, git: GitRunner = nodeGitRunner()): Promise {