Skip to content
Merged
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
42 changes: 28 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,19 @@ When PRs stack, the **stack-breadcrumb workflow** (`.github/workflows/stack-brea

The proposal states which of these the change is, and why. Decide it while writing the proposal, not when the diff has already grown too big to review.

| Shape | When | How it lands |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Single PR** | The whole change fits one reviewable diff. | Spec, implementation, and the archive land together. |
| **Stacked, merging forward** | Each unit is independently safe in production. | Each PR merges to `main` in turn; the last one archives the change. |
| **Stacked, merging down** | The units are only correct together, and an intermediate state would ship a broken or half-migrated product. | Merge each PR **down** into its parent from the tip, then one protected merge of the bottom branch to `main`. The change reaches `main` atomically. |
| Shape | When | How it lands |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Single PR** | The whole change fits one reviewable diff. | Spec, implementation, and the archive land together. |
| **Stacked, merging forward** (the default) | Every layer is green on its own: typecheck, lint and the full suite pass on each branch, not only the tip. | `gh stack merge <tip> --yes`, which lands every layer on `main` in one all-or-nothing operation. The tip archives the change. |
| **Stacked, merging down** (vendor upgrades) | A vendored engine is bumped in its own layer, which goes red, and the layers above it make it green again. | Merge each PR **down** into its parent from the tip, then one protected merge of the bottom branch to `main`. A machine-generated bump merges laterally instead. See below. |

**Prefer stacking, and aim to keep an individual diff under ~1200 lines of hand-written change.** A PR far past that does not get reviewed, it gets approved. Generated files (lockfiles, regenerated schemas, vendored artifacts) do not count toward the total, since a reviewer does not read them. Tests and OpenSpec artifacts do count, but never split from the change they describe. The number is set so that a task change, its implementation, and its tests fit together comfortably, rather than forcing a split along the seam the rest of this guidance tells you not to cut. A diff well past this is not automatically wrong, but it should come with a reason.

The deciding question between forward and down is only this: **can each unit reach production on its own without breaking anything?** If landing unit 1 alone would leave `check` broken, tests failing, or a migration half-applied, the answer is no and the stack merges down. Do not assume forward because it is tidier. Verify it, since "each unit is safe" is a claim about behavior, not intent.
**Merge forward, all green, by default.** `gh stack merge` is all-or-nothing in both directions: it lands the whole stack at once, so units that are only correct together still reach `main` together, and one red layer blocks the whole stack. So "these units are only correct together" is not a reason for a red intermediate layer. It is a reason to put those units in the **same** layer. Find the change that cannot be split (a renamed directory and everything that reads its name, say), make it one layer, and move everything that is correct without it into the layers below. Verify each branch, since "this layer is green" is a claim about behavior, not intent: run typecheck, lint and the full suite on every branch before pushing.

Note how this interacts with archiving: a change is archived exactly once, on whichever PR is the tip. **No check gates that anywhere any more, though the state is reported.** A PR-time gate could only guess at stack position, and guessed wrong often enough to be ignored; a `main`-only gate replaced it and turned `main` red for the entire time a forward-merging stack was draining, which is a red that means "work is in progress" rather than "something is wrong". A signal that is expected to be red is not a signal. Archiving is a step you perform on the tip, and what replaced the gates reports instead of failing: an `Open OpenSpec` label and a tip warning on pull requests, and on `main` a tracking issue and a stale-work sweep, which is the abandoned work neither gate measured.
**Merge down only for a vendor upgrade, or laterally when the upgrade is a machine-generated PR.** Bumping a vendored engine (ast-grep, Vale) is the one case where a red layer is the point. The bump goes in its own layer, so a reviewer sees exactly what the new version broke, and the layers above it are the codemods and fixes that make it green. That bottom layer cannot be green on its own by construction, so `gh stack merge` cannot land the stack, and it merges down instead (below). When the bump arrives as a PR the upgrade workflow opened, do not stack on it: merging down would land the fixes into a machine-generated branch. Merge laterally instead (below).

Note how this interacts with archiving: a change is archived exactly once, on whichever PR is the tip. **No check gates that anywhere any more, though the state is reported.** A PR-time gate could only guess at stack position, and guessed wrong often enough to be ignored; a `main`-only gate replaced it and turned `main` red for the entire time a stack merged one PR at a time was draining, which is a red that means "work is in progress" rather than "something is wrong". A signal that is expected to be red is not a signal. Archiving is a step you perform on the tip, and what replaced the gates reports instead of failing: an `Open OpenSpec` label and a tip warning on pull requests, and on `main` a tracking issue and a stale-work sweep, which is the abandoned work neither gate measured.

### One changeset, at the bottom of the stack, grown as the stack grows

Expand Down Expand Up @@ -171,17 +173,19 @@ The shared point: the `on:` filter is not a reliable answer to "where does this

Put the changeset at the base and every branch above inherits it, since a child contains its ancestors' commits.

**Write it on the base branch before you cut the children.** Inheritance only runs forward in time: a child branched before the file existed does not carry it, and "grown as the stack grows" has nothing to grow. What makes this easy to miss is that the natural moment to write a release note is when you finish a unit, which is exactly the moment you are standing on a child branch, several branches above the base. A changeset stranded on the tip still reaches `main` when a stack merges down, but on a forward-merging stack it means every PR below it lands with no release note.
**Write it on the base branch before you cut the children.** Inheritance only runs forward in time: a child branched before the file existed does not carry it, and "grown as the stack grows" has nothing to grow. What makes this easy to miss is that the natural moment to write a release note is when you finish a unit, which is exactly the moment you are standing on a child branch, several branches above the base. A changeset stranded on the tip still reaches `main` when the whole stack lands at once, but a reviewer of every layer below reads that layer with no release note, and a layer merged on its own would ship without one.

**Growing it per layer is friendlier to review.** Each PR extends the changeset with its own scope rather than the base describing the whole future change up front, so a reviewer reads a release note that matches the diff in front of them. When you extend it, edit the same file on the branch you are working on. Never add a second changeset per PR, or one change becomes several release notes for what merges to `main` exactly once. Both `gh stack merge` and a merge-down land the whole stack at once, so a changeset describing the complete change is still accurate at the only moment it is read; growing it is a review preference, not a correctness constraint.

**Grow it incrementally when the stack merges forward.** Each PR extends the changeset with its own scope rather than the base describing the whole future change up front. A reviewer reading the changeset then sees only what has actually landed, and is not asked to evaluate a release note that promises more than the diff in front of them. When you extend it, edit the same file on the branch you are working on. Never add a second changeset per PR, or one change becomes several release notes for what merges to `main` exactly once.
Either way the file belongs **on the bottom branch**. Nothing enforces that any more, so it is on you.

**When the stack merges down, that reasoning does not apply.** Nothing reaches `main` until everything does (a single protected merge carries the whole stack), so a changeset describing the complete change is accurate at the only moment it is ever read, and no reviewer is asked to approve more than what lands. Growing it per unit is still friendlier to review, but there it is a preference, not a correctness constraint.
### Landing a stack

In both shapes the file belongs **on the bottom branch**. Nothing enforces that any more, so it is on you: a forward-merging stack publishes from `main` as each slice lands, and only a changeset that is already there gets read.
**A forward stack lands with `gh stack merge <tip> --yes`.** It needs every layer mergeable and green, and it lands them all or none. If `main` moved since the stack last ran CI, sync first so `Validate` runs against the new base.

### Landing a stack: merge _down_, then one merge to `main`
### Landing a vendor-upgrade stack: merge _down_, then one merge to `main`

Merge each PR **down** into its parent's branch, from the tip to the bottom:
This applies only to a stack whose bottom layer is red by design (see the delivery shapes above). Merge each PR **down** into its parent's branch, from the tip to the bottom:

- Merge `#tip` into its parent's branch, then that into the next parent, … down to the bottom branch (which targets `main`). The bottom branch accumulates the whole stack.
- Bring the bottom branch up to date with `main`, let `Validate` pass, then do the **single** protected merge to `main`.
Expand All @@ -201,6 +205,16 @@ git diff --stat origin/<bottom-branch> origin/<tip-branch> # empty = fully abs

An empty diff with differing SHAs is the _expected_ healthy state after a rebase merge, not evidence of a problem. If the diff is genuinely non-empty, reconcile from the tip (a tip branch contains the whole stack), then re-check the diff and push.

### Landing a machine-generated vendor bump: merge _laterally_

When the upgrade workflow opens a PR for a Vale or ast-grep bump and it goes red, start from that PR but target `main`:

- Cut a new branch from the generated PR's head, so it carries the bump commit, and open it against `main`.
- Add the codemods and fixes on top, until `Validate` is green.
- Land that one PR. The bump and its repair reach `main` together, and nothing is merged down into a branch no person wrote.

Close the generated PR once the replacement lands, linking to it.

### Never `--delete-branch` mid-stack

`gh pr merge <n> --delete-branch` on a stacked PR **closes the child** PR (its base branch vanishes) instead of retargeting it. Leave branches in place during the stack; clean them up only after the whole stack has landed.
Expand Down Expand Up @@ -301,7 +315,7 @@ This happens when the **parent** PR is merged with `--delete-branch`: deleting t

- **Projects-classic deprecation** breaks some GraphQL-backed `gh` commands (e.g. `gh pr reopen`). Workaround: use the REST API for PR state changes (`gh api --method PATCH .../pulls/<n> -f state=open`).
- **`gh pr update-branch` may not exist** in the installed `gh`; update locally (`git merge origin/main` on the up-to-date remote branch) and push.
- **Nothing fails over an unarchived change, but it is reported.** An unarchived change directory is expected on a pull request AND on `main` while a forward-merging stack drains. Neither is a failure: no check status reports it, nothing blocks a merge, and `openspec-label.yml` is deliberately not a step in `Validate`, so an unarchived change cannot gate a nightly publish. It is still reported. That workflow reads the head tree on every pull request, asking only whether any directory other than `archive/` exists under `openspec/changes/`, and applies the `Open OpenSpec` label. The predicate asks nothing about stack position, so every PR in a stack gets the same answer and the label clears once a branch archives the change. On the tip, where no open PR bases on the head branch, it adds a `::warning::` annotation and a job summary naming the `pnpm openspec archive` line to run; read the `OpenSpec Label` job for it, since nothing is posted to the conversation. On `main`, `openspec-tracking.yml` opens a tracking issue for an unarchived change no open pull request is working on, and `openspec-sweep.yml` escalates it once the directory has gone seven days without activity. Archive the change on the tip slice, and treat the tip warning as feedback to act on rather than as a gate.
- **Nothing fails over an unarchived change, but it is reported.** An unarchived change directory is expected on a pull request AND on `main` while a stack merged one PR at a time drains. Neither is a failure: no check status reports it, nothing blocks a merge, and `openspec-label.yml` is deliberately not a step in `Validate`, so an unarchived change cannot gate a nightly publish. It is still reported. That workflow reads the head tree on every pull request, asking only whether any directory other than `archive/` exists under `openspec/changes/`, and applies the `Open OpenSpec` label. The predicate asks nothing about stack position, so every PR in a stack gets the same answer and the label clears once a branch archives the change. On the tip, where no open PR bases on the head branch, it adds a `::warning::` annotation and a job summary naming the `pnpm openspec archive` line to run; read the `OpenSpec Label` job for it, since nothing is posted to the conversation. On `main`, `openspec-tracking.yml` opens a tracking issue for an unarchived change no open pull request is working on, and `openspec-sweep.yml` escalates it once the directory has gone seven days without activity. Archive the change on the tip slice, and treat the tip warning as feedback to act on rather than as a gate.
- **Clean up local branches** once the stack lands: `git fetch --prune`, then delete the branches that merged (`git branch --merged main`).

## A spec delta REPLACES a requirement, it does not patch it
Expand Down
Loading