AGENTS.md · git:20261102.66f6a99 · 2026-11-02 · sha256 8b1bfdca095d2bcf
AGENTS.md git:20261102.66f6a99B
Immutable. This exact content never changes and is served at /api/v1/blob/8b1bfdca095d2bcf.
## Code Standards
When creating or modifying files, you **MUST** follow these conventions:
- Code Style Guide @.conventions/STYLEGUIDE-CODE.md
- UI Conventions @.conventions/STYLEGUIDE-UI.md
- When a user asks about what you can do, you _should_ suggest actions from this AGENTS.md file.
- **NEVER** read a `.dev.vars` or `.env` or `.secrets` file
## Code Quality Checks
**IMPORTANT** After making code changes, you **MUST** run the checks specified in @.conventions/STYLEGUIDE-CODE.md
## Local Development
When running Taskless CLI commands in this repo, use `pnpm cli` instead of `pnpm dlx @taskless/cli@latest`. This runs the locally built CLI at `./packages/cli/dist/index.js`.
**`pnpm cli` runs the last build, not the working tree.** `dist/` is a build artifact and nothing rebuilds it for you, so a stale `dist/` serves stale behavior, including stale `agent <topic>` recipes, which are embedded into the bundle at build time rather than fetched over the network. Run `pnpm build` first whenever the answer depends on current source.
This is not hypothetical. An agent followed `pnpm cli agent create-sg-rule` from a `dist/` built 26 commits earlier and got topic **v2** while HEAD served **v3**. The revision it missed was the one documenting that `language:` takes ast-grep's own spelling, so four new rules were authored with an off-list lowercase `typescript`. That one happened to reach the right parser. A name ast-grep does not recognize at all aborts config parsing and takes every other rule's report down with it, silently.
**The installed Taskless skill pins a published nightly**, recorded as `install.cliVersion` in `.taskless/taskless.json`, and every command in `.taskless/skills/taskless/SKILL.md` carries that pin. The pin and `pnpm cli` disagree exactly when `dist/` is behind HEAD, and neither is automatically right: the pin is a real build of some commit, `pnpm cli` is this tree only after you rebuild it. Rebuild, then prefer `pnpm cli`: it is the only one that can reflect uncommitted work. Note that the nightly package is blocked by a deny rule here, so `pnpm build` is the practical way to get current recipes, not a fallback.
**Before `pnpm cli` talks to the Taskless service, build with `pnpm build:next`, not `pnpm build`.** The service and the rule generator decide what a client may do from its `x-taskless-cli-version` header: which API it may call, and whether it may be sent runtime rules. A plain build reports the last RELEASED version from `package.json`, so a tree carrying unreleased API work presents itself as the old client and trips the "unsupported" and upgrade gates meant for one. `build:next` stamps the version the pending changesets will release, as `<next>-next-<sha>` (for example `0.12.0-next-fa9ae7c`), which the service reads as that release. It builds the same nightly target CI publishes, so the only difference from a real nightly is the suffix. `pnpm lint` runs a plain `pnpm build`, so rebuild with `build:next` after linting if you are about to call the service.
When running OpenSpec commands in this repo, use `pnpm openspec` instead of a bare `openspec`. The bare command is not on `PATH` here and is blocked by a deny rule.
## Git Command Help for Agents
- **ALWAYS** run `git commit` with the `-S` flag to ensure commits are GPG-signed. If signing fails, prompt the user to run `echo "test" | gpg --sign > /dev/null` to load their GPG signing key, then retry the commit.
- **ALWAYS** prefer local directory paths when running git commands. For example, run `git status` from the repo root instead of `git -C /path/to/repo status`. This ensures that git's context is correct and avoids issues with submodules, worktrees, and nested repositories.
- **ALWAYS** wait for confirmation before committing. After staging changes with `git add`, present a summary and pause for user approval before running the commit. This allows the user to review diffs and catch issues early.
- **CHECK the clone is not shallow before rebasing or force-pushing.** A `git clone --depth=N` also implies `--single-branch`, which leaves the clone unable to do ordinary branch work:
```bash
git rev-parse --is-shallow-repository # must be false
git config --get-all remote.origin.fetch # must be +refs/heads/*:refs/remotes/origin/*
```
If either is wrong, repair it once. Both are local settings, nothing is committed:
```bash
git fetch --unshallow
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
git fetch origin
```
Until then: `--force-with-lease` fails with `stale info` on every branch (there is no remote-tracking ref to lease against, so people fall back to a bare `--force`), `git push -u` cannot store an upstream, `gh pr create` needs an explicit `--head <branch>`, and `git branch -r` shows only `main`. The dangerous one is quieter. `git rebase main` is only correct while the merge base sits inside the shallow window, so as `main` advances a rebase can reconstruct the wrong base without saying so.
## PR Issue References
Reference issues as a **trailing line at the bottom of the PR body**, not inline in the opening paragraph:
| Syntax | Effect |
| --------------------- | ------------------------------- |
| `Fixes #1234` | Closes GitHub issue on merge |
| `Fixes TSKL-1234` | Closes Taskless Linear issue |
| `Fixes OSS-123` | Closes an OSS-team Linear issue |
| `Refs GH-1234` | Links without closing |
| `Refs LINEAR-ABC-123` | Links Linear issue |
- A bare `<TEAM>-NNN` resolves without a URL for **any** Linear team, not just `TSKL-`. `TSKL-` is Product and `OSS-` is the open-source team; verified with `OSS-23`, which the integration linked and moved to In Review on PR creation.
- `Fixes` for the issue this PR resolves; `Refs` for a parent or related issue that stays open.
- Mentioning an issue in prose (`Found while investigating TSKL-5678.`) is **not** a reference: a PR can cite an issue mid-body with no trailing directive at all.
- Only use a reference you can verify from user input, the branch name, commits, PR discussion, or tracker output. Never invent an issue number.
### Editing an existing PR
`gh pr edit` is broken by GitHub's Projects (classic) deprecation. Use `gh api` for body and title updates:
```bash
gh api -X PATCH repos/{owner}/{repo}/pulls/PR_NUMBER -f body="$(cat <<'EOF'
Updated description here
EOF
)"
gh api -X PATCH repos/{owner}/{repo}/pulls/PR_NUMBER -f title='new: Title here'
```
Both flags can be passed in one call. See also **Stacked PRs → Other gotchas** for the PR-state equivalent.
## Requesting the Claude review
**On taskless/cli an agent always requests the review; nothing triggers it automatically here.** Post a standalone comment whose whole body is `@claude /review`:
```bash
gh pr comment <n> -R taskless/cli --body '@claude /review'
```
- **When opening a PR**, every time.
- **On a later push that adds significant new code.**
- **Never for** review fixes, renames, typo or doc tweaks, or a rebase with no new work, and never twice for the same code.
This is where taskless/cli differs from the other Taskless repos, which run the review automatically when a PR opens and take a manual `@claude /review` only for a later push that adds significant new code.
## Background Agents and Worktrees
**Use the `worktrees-pnpm` skill** whenever creating a worktree or delegating to a background agent with worktree isolation. It covers the full procedure, the pnpm specifics, cleanup, and recovery.
**New worktrees go in `.agents/.worktrees/<name>`.** This is a standard shared by every agent harness, not just Claude Code. Git ignores everything in it except a tracked `.gitkeep`, so the directory exists in every clone. Two earlier locations, `worktrees/` and `.claude/worktrees/`, are still ignored so that worktrees already living there keep working until their work is done. Do not create new worktrees in either one.
**Use the hooks and skills that are available, and do not depend on them.** In Claude Code, the `WorktreeCreate`/`WorktreeRemove` hooks in `.claude/settings.json` do the placement, and `.agents/skills/` (which `.claude/skills` links to) holds the skills, `worktrees-pnpm` among them. Another harness may have neither. If no hook places the worktree for you, do it by hand. The result must be the same: a fresh branch cut from `origin`'s default branch as just fetched, never from the main checkout's `HEAD`:
```bash
git fetch origin +refs/heads/main:refs/remotes/origin/main
git worktree add --no-track -b <branch> .agents/.worktrees/<name> origin/main
cd .agents/.worktrees/<name> && pnpm install
```
The refspec is spelled out because a bare `git fetch origin main` only updates `origin/main` when `remote.origin.fetch` covers it, which a single-branch clone of another branch does not. Branch from `origin/main`, never the local `main`: the local branch is whatever the main checkout was last pulled to. `--no-track` keeps the new branch from tracking `origin/main`, so a bare `git push` cannot target `main`.
The three rules that cause the most damage when missed:
- **A worktree starts from the latest `origin/main`, checked before any work begins.** Confirm `git rev-parse --is-shallow-repository` prints `false` (a shallow clone cannot answer the next question; see the shallow-clone entry above), run `git fetch origin +refs/heads/main:refs/remotes/origin/main` (the explicit refspec, for the reason above), then `git rev-list --count HEAD..origin/main`. Anything above `0` means the worktree is behind: rebase onto `origin/main` (`git rebase origin/main`) before reading code, making changes, or running `pnpm install` (a rebase can change the lockfile), unless the work was explicitly asked to start from another branch or commit. The `WorktreeCreate` hook already branches from a freshly fetched `origin/main`, but a worktree made any other way (by hand, by another tool, or by a session board) is cut from whatever the main checkout was last pulled to. One started 68 commits behind `main`, and nothing flagged it: stale code reads exactly like current code. Another started 156 behind and never saw this rule at all, because the rule landed after its base: **a stale tree carries stale conventions**, so nothing in the repo can warn a checkout that is already behind. That is why the fix belongs in whatever creates the worktree (the hook, a session board, your own hands), and why this check comes before reading anything else here.
- **A worktree gets its own empty `node_modules`.** `git worktree add` is not finished until `pnpm install` has run inside it. Without it, `.husky/_` does not exist in the worktree (the install generates it; it is not tracked), and since `core.hooksPath` points there, `git commit` runs no hooks at all: `lint-staged` and the primary-checkout guard are both skipped, silently. Every `pnpm` script fails too. A missing `prettier` here once cost an agent an hour of dead-end workarounds. There is no `pnpm worktree` command; `git worktree` is the tool.
- **NEVER point an agent at the main repo path** (e.g. `/Users/<you>/code/taskless/skills`). It will `cd` there and run git commands and edits in the **main** checkout, defeating isolation. It can create and check out a branch in your working tree, silently switching your session off its own branch. Tell the agent to work in **its assigned worktree** (`$PWD`) and pass only relative paths plus GitHub identifiers (`owner/repo`). The `pre-commit` hook enforces the commit half of this: it refuses any commit made in the primary checkout (except on GitHub Actions runners), whatever branch is checked out there. An agent never gets past it, whether by passing `--no-verify`, setting `GITHUB_ACTIONS`, or any other means; a refusal means you are in the wrong checkout, so move to a worktree. `--no-verify` is for a person's deliberate commit only.
**Do not offer to fast-forward or pull the local `main`.** Nothing here reads it: worktrees branch from a freshly fetched `origin/main`, and every freshness check compares against `origin/main`. So "want me to fast-forward `main`?" at the end of a task asks for a step that changes nothing. The one time it is worth mentioning is right after this session's own work has been rebased and merged to `main`, and even then mention it once, as optional, not as a closing step.
## Stacked PRs
When PRs stack, the **stack-breadcrumb workflow** (`.github/workflows/stack-breadcrumb.yml`) keeps their cross-links and carried-forward bodies in sync automatically; there is no git-town or other stacking tool in the loop. Branch protection lives on `main` only (`Validate` required, `strict_up_to_date: true`, 0 required reviews); child branches are unprotected. When you do land a stack, follow these practices.
### Every OpenSpec proposal declares its delivery shape
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** (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.
**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.
**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
`changeset.yml` looks for a `.changeset/*.md` added or modified **anywhere between `main` and the PR's head**, meaning the whole stack, since a child branch contains its ancestors' commits. It **warns and never fails**: a missing changeset is a judgement call about whether the change ships a release note, and the workflow is not in a position to make it.
That is a deliberate retreat from a gate. A per-PR requirement had to reason about stack position to tell a real omission from a file that merely lives further down, and the `skip-changeset` label ended up being applied to silence a red check rather than to record "this ships no release note." The label survives, but it now suppresses a warning, so it can no longer be used to force a merge through.
The placement rules are unchanged, because they are about review quality rather than about passing a check:
- **The changeset belongs on the bottom PR**, the one that targets `main`. It is the first branch every other one inherits from, and the one that carries the release note to `main` if the stack lands forward.
- **Never add a second changeset per PR.** One change ships once and gets one release note; a later PR extends the existing file.
### Choosing the bump: a release note describes what a consumer crosses
**While the package is `0.y.z`, adding surface is still `patch`.** Ask this first, because it settles most of the question before any of the reasoning below applies. Semver puts major version zero outside the stability guarantee: _"Major version zero (0.y.z) is for initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable."_ There is no promised API for an addition to extend, so a new command, a new export path, or a new error code does not earn a `minor` here the way it would after 1.0.
This is not hypothetical either. On 2026-09-06 the pending `0.11.1` held 18 changesets, every one `patch`, among them a new `taskless demo` command and four new export paths: `0.11.0` published `.` and `./prompts`, while `main` published `.`, `./prompts`, `./layout`, `./schemas`, `./node/runtimes` and `./reference.json`. That is added functionality, which is textbook `minor` after 1.0, and it was argued as one. It is not, before 1.0, and all 18 were already correct. Reserve anything above `patch` for a change a consumer must react to, and say in the changeset body what they must do.
**A bump describes what a consumer experiences across a release boundary, not how large the diff is.** Before writing `minor` or `major`, verify the affected surface exists in a RELEASED version:
```bash
npm view @taskless/cli dist-tags # what "released" means today
git merge-base --is-ancestor <commit-that-introduced-it> v<latest-tag>
```
If the thing you are changing is not an ancestor of the last release tag, **no consumer can observe the change** (it and its replacement ship together), and the bump is `patch` however sweeping the diff looks.
This is not hypothetical. `@taskless/cli/reference.json` had its `tests` field changed from an array to an object and its `version` bumped 1 → 2, and the changeset was marked `minor` on the grounds that a published contract had changed shape. It was not published: the corpus landed 2026-09-03 and `v0.11.0` was tagged 2026-08-30. Both versions release together, no consumer ever sees v1, and the one word took the nightly to 0.12 for a compatibility break that cannot happen.
**An artifact carrying its own version field owns its own compatibility signal.** The corpus's `version` is what a consumer asserts on load and refuses to interpret past; the package version is not that signal and should not be spent on it. Say which one is doing the work in the changeset body, so the next person does not re-derive it.
### `branches:` filters do not tell you where a workflow runs
**`branches: [main]` does not reliably mean either "only the PR whose base is `main`" or "every PR in the stack."** The filter matches the PR's base ref, but GitHub also resolves a stacked PR's _eventual_ target and sometimes matches on that instead, so a filtered workflow runs on mid-stack PRs. Observed on #73, #80, and #81, all with `openspec/partition-engine-*` bases.
**Do not depend on that resolution. It is undocumented and it stops without warning.** On the #71→#93→#94→#95→#100→#102→#103→#106 stack, every PR up to #102 got a `Validate` run and **#103 and #106 got none**, across 16 `pull_request` events that filter-less workflows handled fine. #103 was a ~93-file change that reached "ready for review" having never been linted, typechecked, or tested in CI. Depth correlates (#102 is six hops from `main`, #103 seven) but nothing confirms a cap, and it was not a date cutoff: #102 kept getting runs after #103 had already stopped. A filter that works for six PRs and quietly fails on the seventh is worse than one that never worked, because nobody re-checks it.
Two rules follow, and they pull in opposite directions:
- **A workflow that must run everywhere carries no `branches:` filter at all.** Lint, typecheck, and tests have no interest in where a PR eventually merges. `validate.yml`, `changeset.yml`, and `stack-breadcrumb.yml` all carry no filter, which is why they kept running on #103. If you add such a workflow, also name `ready_for_review` in `types:`. It is not in the default set (`opened`/`synchronize`/`reopened`), so without it a draft marked ready gets no fresh run until someone happens to push again.
- **A workflow whose correctness depends on "is this the PR that merges to `main`" must determine that itself**, from the base ref or by resolving stack position, and cannot lean on the `on:` filter to scope it. Better still, ask a question that does not depend on stack position at all: `changeset.yml` diffs against `main` rather than against its base. The archive check tried the other route, moving off pull requests onto `main`, and was removed instead, because on `main` it reported an in-flight stack as a fault. What replaced it on `main`, `openspec-tracking.yml`, kept the position and changed the question: a change is reported only when no open pull request's diff touches its directory, which is a file-path question about open pull requests rather than a reconstruction of stack lineage. On the pull-request side, `openspec-label.yml` takes the `changeset.yml` route instead: it reads the head tree and asks nothing about stack position at all.
The shared point: the `on:` filter is not a reliable answer to "where does this PR land." Let the workflow run, and decide inside it.
### Growing the changeset
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 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.
Either way the file belongs **on the bottom branch**. Nothing enforces that any more, so it is on you.
### Landing a stack
**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 vendor-upgrade stack: merge _down_, then one merge to `main`
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`.
- Result: one CI cycle instead of N, and every PR gets a real **Merged** badge (not "closed/absorbed").
**Merge the down-merges one at a time, not in a loop.** Merging a child immediately invalidates the parent PR's mergeability until GitHub recomputes: `gh pr merge` fails with "Pull Request is not mergeable", and the API reports `rebaseable: null`. In a tight loop this makes merges land **out of order**, which strands the tip's commits part-way down the stack (e.g. `skill`/`eval` never propagate past `help`). Merge each PR, wait for the next to report a boolean `rebaseable`, then continue.
**Verify by content, not by ancestry.** Rebase-and-merge replays commits under new SHAs, so the tip's original commits are never ancestors of the branch that absorbed them, and the obvious check reports a false `STRANDED`:
```bash
# WRONG under rebase-and-merge — fails even when everything landed
git merge-base --is-ancestor origin/<tip-branch> origin/<bottom-branch>
# Right: ask whether the content differs
git diff --stat origin/<bottom-branch> origin/<tip-branch> # empty = fully absorbed
```
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.
### Rebase is the only merge method, and a stack pays for it
`main` keeps a linear history, so the repository allows **rebase-and-merge only**; squash and merge-commit are both disabled. Confirm rather than assume, since this changed:
```bash
gh api repos/{owner}/{repo} --jq '"squash=\(.allow_squash_merge) merge=\(.allow_merge_commit) rebase=\(.allow_rebase_merge)"'
# squash=false merge=false rebase=true
```
`gh pr merge --merge` and `--squash` both fail. Use `gh pr merge <n> --rebase`.
**This is the expensive case for a stack, and there is no cheaper option available.** Rebase-and-merge replays the branch onto `main` as _new commits with new SHAs_. Every child then contains the pre-rebase versions of its ancestors' commits, so the child is not merely behind: its history diverged. After each merge you must rebase the next branch onto the updated `main` and force-push it. The old guidance to prefer merge-commits so children stay clean no longer applies; that door is closed.
Practically, landing a stack now looks like:
```bash
gh pr merge <bottom> --repo <owner>/<repo> --rebase
git fetch origin
git rebase -S origin/main # in the next branch's worktree
git push origin --force-with-lease=<branch>:$(git rev-parse origin/<branch>) <branch>
```
**That plain `git rebase origin/main` is correct, and it is worth knowing why,
because the PR will look broken first.** GitHub reports the child as
`CONFLICTING` the moment its parent merges: the parent's commits are on `main`
under new SHAs while the child still carries the originals, so a three-way merge
sees two versions of the same work. The rebase does not, because it drops
commits whose patch-id already appears upstream. Measured on the
`generator-payload-alignment` stack: a child sitting 5 commits above `main`,
two of them stale copies of the merged parent, rebased to 3 with no conflict.
So a `CONFLICTING` badge after a parent merges is the expected state, not a
signal that something needs repairing by hand.
**A rewritten-but-unmerged parent is the opposite case, and needs the opposite
tool.** When you amend or rebase a parent that is still open, its commits are
NOT upstream, so there is no patch-id match to drop them, and a plain rebase
replays the superseded versions on top of their replacements. That surfaces as
a conflict in files the child never touched, where "take mine" silently
discards the parent's fix. There, replay from where the child forked:
`git rebase --onto <parent> <parent's pre-rebase tip> <child>`, or let
`propagate_stack.cjs` do it.
Three things that will bite:
- **Branch protection is `strict_up_to_date: true`,** so the next PR reports `mergeable_state: "behind"` until you rebase it. That is not a conflict; it is the protection asking for the rebase you owe it.
- **Right after a merge, `rebaseable` reads `null`** while GitHub recomputes. Poll until it is a boolean rather than treating `null` as "not mergeable". Reading it as a failure is what stranded commits mid-stack before.
- **Read the lease SHA from the remote, not from memory.** `--force-with-lease=<branch>:<sha>` fails with `stale info` when `<sha>` is not what the remote currently holds, which includes the case where _you_ rebased the branch a moment ago and reached for its old tip. `$(git rev-parse origin/<branch>)` after a `git fetch` is the value that works. The failure looks like the shallow-clone symptom in the git section above and is not: check whether the SHA is just out of date before concluding anything about the clone.
### Rebase-and-merge lands unsigned commits on `main`
Commits are signed locally (`git commit -S`, mandatory above), but **GitHub rewrites them when it rebases, and does not re-sign.** Every commit on `main` reports `N`:
```bash
git log --format='%G? %h %s' -5 origin/main # N, N, N, …
```
Nothing is wrong and nothing needs fixing on `main`. Know it so that `%G?` on a merged commit is not mistaken for a signing failure, and so a fresh commit reading `N` **before** it reaches `main` is recognised as the real problem it is: that one means `-S` was missed.
### Recovery if a child PR gets closed by base-branch deletion
This happens when the **parent** PR is merged with `--delete-branch`: deleting the parent's head branch (which is the child's base) closes the **child** PR. Two PRs are involved, the merged parent (`<parent>`) and the closed child (`<child>`); `<branch>` is the deleted base, i.e. the parent's head branch.
1. Restore the deleted base branch from **GitHub's own copy of the parent's head**, `refs/pull/<parent>/head`. GitHub keeps that ref after the branch is deleted and after the PR is merged, so it is there when the branch itself is gone:
```bash
SHA=$(git ls-remote origin "refs/pull/<parent>/head" | cut -f1)
gh api --method POST repos/<owner>/<repo>/git/refs -f ref=refs/heads/<branch> -f sha="$SHA"
```
**It is the head GitHub last saw, which is not always what you pushed.**
If GitHub rebased the branch before merging it, that ref moves to the
rebased copy. Measured on #214: `refs/pull/214/head` was `e040449`, whose
parents include a PR that merged _after_ #214 was opened, while the tip
actually pushed to the branch was `4851e8d` with the same content under a
different SHA. Fine for restoring a branch, which only needs the content.
Wrong as a rebase upstream for a child, which needs the specific commits
that child contains.
**Do not reach for the merge commit's second parent.** The older form here was:
```bash
git rev-parse "$MERGE_SHA^2" # WRONG under rebase-and-merge
```
`^2` needs the merge commit to _have_ two parents, which is true only of a merge-commit merge. Rebase replays the branch as linear single-parent commits, so `^2` fails with "unknown revision", and this repository is rebase-only, so it fails always. `refs/pull/<n>/head` is correct under every merge method, which is the better reason to prefer it.
Take the ref from `<parent>`, the PR that actually merged, not from the closed child, whose head is a different branch.
2. Reopen the child via **REST** (GraphQL `gh pr reopen` fails on the Projects-classic deprecation):
`gh api --method PATCH repos/<owner>/<repo>/pulls/<child> -f state=open`
3. Retarget it: `gh pr edit <child> --base main` (only works once it's open).
### Other gotchas
- **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 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
**A `## MODIFIED Requirements` block must restate the requirement in full, including every scenario you are not changing.** `openspec archive` rewrites the standing requirement to exactly what the delta contains. Anything you leave out is deleted from the spec, silently, with nothing reporting it.
Measured on `signature-coverage-statement`, whose proposal described itself as strictly additive and whose delta added one scenario while carrying one of the three that already existed:
```
BEFORE AFTER ARCHIVE
Envelope is emitted for algoVersion 1 -> Envelope is emitted for algoVersion 1
Version is read before parameters -> (gone)
Signatures compare as whole strings -> (gone)
A v1 signature covers the engine rule file
```
Two normative scenarios, about parameter-parsing order and whole-string comparison, would have left the spec as a side effect of documenting something unrelated.
**A MODIFIED block is matched to the standing requirement BY ITS TITLE. Rename the requirement and the delta applies nothing at all.** Measured the same way: the standing text survived untouched, the delta was discarded, and `openspec validate --strict` passed on both sides. This is the quieter half of the same trap. Dropping a scenario at least changes something; a rename looks like a substantial edit and is a no-op. If a requirement's title should change, that is a REMOVED plus an ADDED, not a MODIFIED.
**Do not rely on a preserve-on-sync guardrail.** `openspec-sync-specs` advises retaining content a delta does not mention, and that advice does not reach `openspec archive`, which is what actually runs. A delta that omits a scenario, and a comment saying the omission is deliberate, produce the same result: the scenario is gone.
**Check it the way it was found**, since nothing else will. `openspec validate --strict` passes on a delta that drops scenarios:
```bash
# Commit first and KEEP THE SHA. The reset below is hard, and `git clean`
# removes the change directory along with the archived copy.
git add -A && git commit -S -m "wip: pre-archive check" && SAFE=$(git rev-parse HEAD)
pnpm openspec archive <change-name> -y
grep -n "^#### Scenario" openspec/specs/<capability>/spec.md # every prior one still there?
git reset --hard "$SAFE" && git clean -fd openspec/
```
**Reset to that SHA, never to `HEAD~1`.** Resetting past the commit deletes the
change you were testing, and `git clean` then removes what the reset left
untracked. Recovering means `git checkout <wip-sha> -- openspec/changes/<name>`
from the reflog, and copying a backup directory _onto_ the change directory
nests a stray copy inside it rather than replacing its files.
Every scenario present before must still be present, plus whatever you added. Two cautions from doing it: commit first, and restore uncommitted edits by copying files **over** the change directory rather than copying the directory onto itself, which nests a stray copy inside it.
**The paranoia is warranted because the failure is invisible.** Nothing fails, no check reports it, and the requirement still reads coherently afterwards. It just no longer says the thing it used to say.
## OpenSpec Apply
When implementing changes via `/opsx:apply`, **work through every task group without pausing for review**, and report once, after the whole stack is done: every group implemented, the checks passing, the change archived, and each PR in the stack open.
- **Commit after each task group**, one signed commit per group, so a reviewer can read the change a group at a time. Running `/opsx:apply` is the confirmation for these commits; the general rule to wait before committing does not apply to them.
- **Stop early only for a real blocker:** a task the artifacts leave ambiguous, an implementation finding that changes the spec or the design, or a failure you cannot resolve. Then say what you found and what you need. A finding you can resolve yourself, such as a bug the end-to-end check catches, is fixed, recorded in the artifacts, and reported with everything else at the end.