Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/agent-driver/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-driver/src/actions.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/agent-driver/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-driver/src/types.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
9 changes: 4 additions & 5 deletions packages/agent-driver/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/skill-branches/SPEC.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/skill-branches/src/branch-links.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/skill-branches/src/branch-links.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/skill-branches/src/branch-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function reconcileBranchLinks(cwd: string, deps: BranchLinksDeps =
const wanted = new Map<string, string>()
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.
Expand Down
2 changes: 1 addition & 1 deletion packages/skill-branches/src/cli.SPEC.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/skill-branches/src/git.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 3 additions & 3 deletions packages/skill-branches/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 },
Expand Down
4 changes: 2 additions & 2 deletions packages/skill-branches/src/reclaim.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Reclaiming an agent's checkout: the one implementation behind every surface that
## Glossary

- **birth branch** - the `agent-<agent id>` 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

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading