enhance(#2971): strict planning filter mode for /gsd-pr-branch (#3720)

* test(#2971): failing-first suite for the pr-branch planning-path filter

Binds the not-yet-built planning.pr_strict mode and the corrected filter recipe
for /gsd-pr-branch across six layers: pure classification and forbidden-path
predicates, real-git fixtures that run the cherry-pick filter loop end to end,
config-key registration through the real CLI and both manifests, the executed
worktree-materialization claim the issue's triage asked to establish, fast-check
properties over arbitrary path sets, and a drift guard over the shipped workflow.

Two live defects in today's shipped recipe are pinned as regressions, both
reproduced empirically first: `git rm -r --cached` stages a deletion of any
.planning/ path the target branch already tracks, so the generated PR removes the
base branch's planning files; and the same command leaves the cherry-picked file
untracked on disk, so a second commit touching that path aborts the pick with
"untracked working tree files would be overwritten" and every remaining commit is
silently dropped.

The test helper parses the canonical path lists out of gsd-core/workflows/pr-branch.md
rather than restating them, so the workflow stays the single source of truth and the
suite cannot drift from what ships.

Refs #2971

* feat(#2971): strict planning filter mode for /gsd-pr-branch

Adds planning.pr_strict — a boolean, default false, that selects what
/gsd-pr-branch means by "filtered". Default mode is unchanged: structural
planning state survives into the PR branch and the nine transient
subdirectories do not. Strict mode drops every .planning/ path, structural
files included, and carries a commit over only when it touches at least one
file outside .planning/.

Strict mode is what makes planning.commit_docs: true safe for a project that
versions its planning tree locally but publishes none of it. The alternative
posture, commit_docs: false, silently costs parallel executor isolation — a
worktree is checked out from a commit, so an untracked or ignored .planning/
is simply absent inside it and the executor has no PLAN.md to read. That claim
is now established by an executed fixture rather than inherited.

The two path lists are declared once and both projections derived from them,
so create_pr_branch and verify can no longer disagree about what the filter
promised. verify previously counted every .planning/ path against a documented
success criterion of zero while create_pr_branch was specified to preserve five
structural files, so a correct run reported itself as failed on every phase
that touched STATE.md — which is every phase. It now asserts against the active
mode, and names the .planning/ paths default mode deliberately keeps rather
than trading a wrong signal for silence.

Two verified defects in the same recipe are fixed alongside, because strict
mode would have amplified both. `git rm -r --cached` staged a deletion for any
.planning/ path the target branch already tracked, so the generated PR removed
the base branch's planning files — under strict mode that would have been the
entire tree. The same command left the picked file untracked on disk, so a
second commit touching that path aborted the cherry-pick with "untracked
working tree files would be overwritten" and every remaining commit was
silently dropped. Both were reproduced against real git before being fixed.
The filter now forces excluded paths back to what the PR branch's HEAD carries,
in the index and the working tree; a conflict outside the filter halts instead
of being improvised past; a commit left empty by filtering is skipped rather
than failing. A clean-working-tree precondition makes the worktree half safe.

Closes #2971

* fix(#2971): unwind the checkout on a conflict halt, and test the real recipe

Two review findings, both fixed in place.

The isolated adversarial pass found that the conflict-outside-the-filter branch
exited while leaving the user checked out on the half-built PR branch with
cherry-pick state still live — this loop runs in the user's own working
directory, so stranding them there is a real cost even though it is not a
vulnerability. The branch now aborts the pick, returns to the original branch,
removes the partial PR branch, and says so before exiting.

The standards pass found the L2 fixtures executed a hand-written mirror of the
cherry-pick filter recipe rather than the recipe itself, so a reordering in the
workflow would not have been caught — and the order is load-bearing, since
restoring a path from HEAD before removing it inverts the filter. The helper now
extracts the canonical loop from the shipped workflow and the fixtures execute
that verbatim, which also gives the conflict-halt unwind above real coverage.
The drift guard additionally pins the two commands' relative order and asserts
the workflow carries exactly one canonical loop.

Also records the publication gate in the CONTEXT.md glossary next to the commit
gate it is distinct from.

Refs #2971

* fix(#2971): make the conflict-halt unwind actually unwind, and use the colon slash form

The remote matrix caught two defects in the previous commit.

The halt path claimed to restore the original branch but did not. `git
cherry-pick --abort` does not apply to a single `--no-commit` pick with no
sequencer file, and the fallback left the unmerged index in place, which makes
`git checkout` refuse — a failure the `2>/dev/null || true` then swallowed, so
the user was told they had been restored while still sitting on the half-built
PR branch. The unwind now drops sequencer state, hard-resets the disposable PR
branch to clear the unmerged index, and only claims a restore when the checkout
actually succeeded; when it does not, it says where the user is and gives them
the two commands to finish it by hand. Verified against real git: exit 1, the
conflict named, HEAD back on the original branch, the partial branch gone, a
clean tree and no CHERRY_PICK_HEAD.

Two runtime-loaded source artifacts used the retired `/gsd-<cmd>` hyphen form,
which names a command no runtime registers. The canonical authoring token for
workflows and references is `/gsd:<cmd>`; docs keep the hyphen form, so the
documentation added in this branch is unaffected. The comment in src/config.cts
moves to the colon form too, since it propagates into the generated lib.

Refs #2971

* docs(#2971): backfill PR number into the changeset fragments (#3720)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-20 15:07:40 -04:00
committed by GitHub
parent 14679b866b
commit 9a69a86f42
22 changed files with 1399 additions and 46 deletions

View File

@@ -1724,6 +1724,10 @@ Create a clean PR branch by filtering out `.planning/` commits.
**Purpose:** Reviewers see only code changes, not GSD planning artifacts.
**Prerequisites:** Clean working tree — uncommitted changes are rejected before the PR branch is created.
**Filter mode:** Set by [`planning.pr_strict`](CONFIGURATION.md#planning-settings). Default (`false`) keeps structural planning state — `STATE.md`, `ROADMAP.md`, `MILESTONES.md`, `PROJECT.md`, `REQUIREMENTS.md`, `milestones/**` — and drops the transient subdirectories. Strict (`true`) drops every `.planning/` path. The active mode is printed in the run header and in the verification summary.
```bash
/gsd-pr-branch # Filter against main
/gsd-pr-branch develop # Filter against develop

View File

@@ -509,6 +509,7 @@ The following combinations of `mode`, `granularity`, `model_profile`, and workfl
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `planning.commit_docs` | boolean | `true` | Whether `.planning/` files are committed to git |
| `planning.pr_strict` | boolean | `false` | Filter mode for [`/gsd-pr-branch`](COMMANDS.md#gsd-pr-branch). `false` — the generated PR branch keeps structural planning state (`STATE.md`, `ROADMAP.md`, `MILESTONES.md`, `PROJECT.md`, `REQUIREMENTS.md`, `milestones/**`) and drops the transient subdirectories. `true` — every `.planning/` path is dropped, structural files included, and a commit is carried over only when it touches at least one file outside `.planning/`. Applies to the root repository's PR branch only; `planning.sub_repos` companion branches are unaffected |
| `planning.search_gitignored` | boolean | `false` | Add `--no-ignore` to broad searches to include `.planning/` |
| `planning.sub_repos` | array of strings | `[]` | Paths of nested sub-repos relative to the project root. When set, GSD-aware tooling scopes phase-lookup, path-resolution, and commit operations per sub-repo instead of treating the outer repo as a monorepo |
@@ -622,6 +623,25 @@ The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and can
When `planning.commit_docs` is `false` and `.planning/` is listed in `.gitignore`, GSD treats planning artifacts as local-only. `planning.search_gitignored: true` ensures broad searches still include the `.planning/` directory in this configuration. See [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) for the full setup, including untracking files git is already tracking.
#### Two ways to keep planning private, and what each costs
"Private planning" is really two different questions — *is it in git at all?* and *does it reach the remote?* — and GSD answers them with two different keys.
`planning.commit_docs: false` answers the first by keeping `.planning/` out of git entirely. That has a cost most projects discover late: **parallel executor worktrees stop working.** A worktree is checked out from a *commit*, so a `.planning/` directory that is untracked or ignored simply does not exist inside it, and the executor has no `PLAN.md` to read. Local-only planning and parallel execution are mutually exclusive under this setting. Untracked planning also has no git history, so `/gsd-undo` and revert paths have nothing to restore.
`planning.pr_strict: true` answers the second instead, and leaves the first alone. Planning artifacts are committed normally on your working branch — so worktrees find them, and history exists — while `/gsd-pr-branch` guarantees that none of them reach the branch you publish. The guarantee is enforced by the command rather than by remembering never to push the working branch.
Pick by which question you are actually asking:
| You want | Setting | What you give up |
|---|---|---|
| Planning never enters git | `planning.commit_docs: false` | Parallel executor worktrees; planning git history |
| Planning is versioned locally but never published | `planning.commit_docs: true` + `planning.pr_strict: true` | Nothing — but the working branch itself must not be pushed; publish the generated `-pr` branch |
The two are independent keys and can be set together, but `pr_strict` is inert when `commit_docs` is `false`: with nothing committed, there is nothing for the PR-branch filter to remove.
See [Publish PRs without planning artifacts](how-to/publish-prs-without-planning-artifacts.md) for the setup.
### `commit_docs` Pre-Commit Guard (opt-in)
`planning.commit_docs: false` only stops GSD's own `gsd-tools commit`/`gsd-tools state`

View File

@@ -1330,6 +1330,14 @@ That third-party dependence is a real trade-off, held honestly rather than paper
- REQ-PRBRANCH-01: System MUST identify commits that only modify `.planning/` files
- REQ-PRBRANCH-02: System MUST create a new branch with planning commits filtered out
- REQ-PRBRANCH-03: Code changes MUST be preserved exactly as committed
- REQ-PRBRANCH-04: System MUST NOT delete a `.planning/` path the target branch already tracks
- REQ-PRBRANCH-05: Verification MUST assert against the active filter mode's contract, not an unconditional zero
**Filter modes.** `planning.pr_strict` selects what "filtered" means. The default mode treats `.planning/` as two populations: structural state that belongs in review (`STATE.md`, `ROADMAP.md`, `MILESTONES.md`, `PROJECT.md`, `REQUIREMENTS.md`, `milestones/**`) and transient per-phase artifacts that do not (`phases/`, `quick/`, `research/`, `threads/`, `todos/`, `debug/`, `seeds/`, `codebase/`, `ui-reviews/`). Strict mode collapses that distinction: nothing under `.planning/` reaches the PR branch, and a commit is carried over only when it touches at least one file outside `.planning/`.
Strict mode exists because the two ways to keep planning private are not equivalent. Turning off `planning.commit_docs` keeps `.planning/` out of git, which also takes parallel executor worktrees with it — a worktree is checked out from a commit, so an untracked planning tree is simply absent inside it and the executor has no `PLAN.md` to read. Strict mode leaves planning committed, so worktrees and revert paths keep working, and moves the guarantee to the publication boundary instead. See [Publish PRs without planning artifacts](how-to/publish-prs-without-planning-artifacts.md).
Both modes filter by forcing the excluded paths back to whatever the target branch already tracks, in the index *and* the working tree. Un-staging alone would record a deletion of any planning file the target branch carries, and would leave the picked file untracked on disk, where it makes a later cherry-pick of the same path abort.
---

View File

@@ -32,6 +32,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) — turn on `state ~N commits back`, and tell "STATE.md is fresh" apart from "freshness could not be established"
- [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md) — read `planning inspect` from a dashboard or harness, and tell "nothing to report" apart from "could not look"
- [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) — make `.planning/` local-only, including untracking files git already tracks (the step `.gitignore` alone cannot do)
- [Publish PRs without planning artifacts](how-to/publish-prs-without-planning-artifacts.md) — keep `.planning/` committed locally, so worktrees and `/gsd-undo` keep working, while `planning.pr_strict` keeps every planning path out of the branch you push
- [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality
- [Verify a dependency-compatibility claim](how-to/verify-a-dependency-compatibility-claim.md) — act on a compatibility claim the researcher left `[ASSUMED]`, and tell "nothing declared" apart from "a constraint is declared" and "the lookup failed"
- [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents

View File

@@ -180,8 +180,21 @@ Windows note: the hook runs under Git Bash, same as any other git hook. GSD's ow
matrix is Linux-only, so this specific behavior is verified on Linux/macOS plus code review, not
by an automated Windows run.
## If you need parallel executors, use the other posture instead
Everything above keeps `.planning/` out of git, and that has one consequence worth knowing before
you commit to it: **parallel executor worktrees stop working.** A worktree is checked out from a
commit, so an untracked or ignored `.planning/` does not exist inside it and the executor has no
`PLAN.md` to read. Untracked planning also has no git history, so `/gsd-undo` and revert paths have
nothing to restore.
If what you actually want is "planning is versioned locally, but never reaches the remote", leave
`commit_docs` on and set `planning.pr_strict: true` instead — see
[Publish PRs without planning artifacts](publish-prs-without-planning-artifacts.md).
## Related
- [Publish PRs without planning artifacts](publish-prs-without-planning-artifacts.md)
- [Configuration reference — `planning.commit_docs`](../CONFIGURATION.md#planning-settings)
- [Configuration reference — auto-detection and the tracked-files caveat](../CONFIGURATION.md#auto-detection)
- [Configuration reference — per-phase override](../CONFIGURATION.md#per-phase-override-phase_commit_docs)

View File

@@ -0,0 +1,113 @@
# Publish PRs without planning artifacts
You want GSD's `.planning/` tree versioned in git on your own branch — so parallel executors can
read their plans and `/gsd-undo` has something to restore — while the pull request your team
reviews contains none of it.
This is the opposite trade to
[Keep planning docs out of a shared repo](keep-planning-docs-private.md), which removes planning
from git entirely and loses worktree isolation in the process. Use this guide when you want the
history and want the remote clean.
Five steps, and the fourth is the one that decides whether the guarantee actually holds.
## 1. Keep planning artifacts committed
```bash
gsd-tools config-set planning.commit_docs true
```
This is already the default. Set it explicitly if someone previously turned it off, or if
`.planning/` is listed in `.gitignore` — a gitignored `.planning/` auto-resolves `commit_docs` to
`false` no matter what `config.json` says, and `planning.pr_strict` cannot filter what was never
committed.
If `.planning/` is currently in `.gitignore`, remove that line before continuing.
## 2. Turn on strict PR filtering
```bash
gsd-tools config-set planning.pr_strict true
```
Confirm it took:
```bash
gsd-tools query config-get planning.pr_strict --raw
```
`true` means every `.planning/` path will be filtered out of the generated PR branch —
`STATE.md` and `ROADMAP.md` included, not only the per-phase artifacts.
## 3. Do the work normally
Nothing about the phase loop changes. `/gsd-plan-phase` and `/gsd-execute-phase` commit planning
artifacts exactly as they always have, executor worktrees find their `PLAN.md`, and your working
branch carries the full planning history.
## 4. Generate the PR branch — and push *that* branch
```bash
/gsd-pr-branch
```
The command creates `<your-branch>-pr` from the target branch and rebuilds the history without any
`.planning/` path. **Push the generated `-pr` branch, never your working branch** — the working
branch is where the planning history lives, and it is the one thing that must not reach the remote.
```bash
git push origin <your-branch>-pr
gh pr create --base main --head <your-branch>-pr
```
Requirements before it will run:
- **A clean working tree.** Uncommitted changes are rejected up front. Commit or stash first.
- **Commits ahead of the target.** With none, the command exits without creating a branch.
## 5. Read the verification summary
The run ends with a summary. Two lines carry the guarantee:
```
Mode: strict
Planning paths in diff: 0 (allowed 0, forbidden 0 — must be 0)
```
`forbidden` is the number that matters. In strict mode it counts every `.planning/` path that
survived into the branch, and it must be `0`. `allowed` is always `0` in strict mode — there is no
allowed population.
### What each outcome means
| Summary line | Meaning | What to do |
|---|---|---|
| `Mode: strict` … `forbidden 0` | The guarantee held. | Push the `-pr` branch. |
| `Mode: default` | `planning.pr_strict` did not resolve to `true`. | Re-run step 2; the key is read from the project's `.planning/config.json`, so check you are in the right project root. |
| `forbidden` greater than `0` | The filter did not remove everything it promised. | Do **not** push. This is a bug — report it with the listed paths. |
| `Conflict outside the .planning/ filter` | A real content conflict between your commits and the target branch. | Resolve it on your working branch, then re-run `/gsd-pr-branch`. |
| `Working tree has uncommitted changes` | Step 4's precondition failed. | Commit or stash, then re-run. |
| `No commits ahead of <target>` | There is nothing to filter. | Not an error — you have not committed anything yet, or the target is wrong. |
An `ℹ️` advisory listing `.planning/` paths appears only in **default** mode, naming paths that are
neither transient nor structural (`config.json`, `intel/`, `workstreams/`) and therefore kept. Strict
mode removes those too, so the advisory never appears — if you see it, you are not in strict mode.
## Verify it for yourself
Before trusting the setup on a real review, confirm the generated branch is clean:
```bash
git diff --name-only main..<your-branch>-pr | grep '^\.planning/'
```
No output is the expected result. Any output means step 2 did not take effect, or the run reported
a non-zero `forbidden` count you should not push past.
## Related
- [Keep planning docs out of a shared repo](keep-planning-docs-private.md) — the other posture:
planning never enters git, at the cost of parallel executor worktrees
- [Planning settings reference](../CONFIGURATION.md#planning-settings) — `planning.pr_strict`,
`planning.commit_docs`, and the rest of the `planning.*` keys
- [`/gsd-pr-branch` reference](../COMMANDS.md#gsd-pr-branch)

View File

@@ -121,6 +121,7 @@ GSD はプロジェクト設定を `.planning/config.json` に保存します。
| 設定 | 型 | デフォルト | 説明 |
|------|-----|-----------|------|
| `planning.commit_docs` | boolean | `true` | `.planning/` ファイルを git にコミットするかどうか |
| `planning.pr_strict` | boolean | `false` | `/gsd-pr-branch` のフィルタモード。`false` は STATE.md、ROADMAP.md、MILESTONES.md、PROJECT.md、REQUIREMENTS.md、milestones/ などの構造的なプランニング状態を PR ブランチに残す。`true` はすべての `.planning/` パスを削除する |
| `planning.search_gitignored` | boolean | `false` | `.planning/` を含めるために広範な検索に `--no-ignore` を追加 |
### 自動検出

View File

@@ -121,6 +121,7 @@ GSD는 프로젝트 설정을 `.planning/config.json`에 저장합니다. `/gsd-
| 설정 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `planning.commit_docs` | boolean | `true` | `.planning/` 파일을 git에 커밋할지 여부 |
| `planning.pr_strict` | boolean | `false` | `/gsd-pr-branch`의 필터 모드. `false`는 STATE.md, ROADMAP.md, MILESTONES.md, PROJECT.md, REQUIREMENTS.md, milestones/ 등 구조적 플래닝 상태를 PR 브랜치에 유지하고, `true`는 모든 `.planning/` 경로를 제거함 |
| `planning.search_gitignored` | boolean | `false` | 광범위한 검색에 `--no-ignore`를 추가하여 `.planning/`을 포함 |
### 자동 감지

View File

@@ -352,6 +352,7 @@ As seguintes combinações de `mode`, `granularity`, `model_profile` e controles
| Configuração | Tipo | Padrão | Descrição |
|---------|------|---------|-------------|
| `planning.commit_docs` | boolean | `true` | Define se os arquivos de `.planning/` são commitados no git |
| `planning.pr_strict` | boolean | `false` | Modo de filtro para `/gsd-pr-branch`. `false` mantém o estado estrutural de planejamento (STATE.md, ROADMAP.md, MILESTONES.md, PROJECT.md, REQUIREMENTS.md, milestones/) no branch de PR; `true` remove todos os caminhos de `.planning/` |
| `planning.search_gitignored` | boolean | `false` | Adiciona `--no-ignore` em buscas amplas para incluir `.planning/` |
| `planning.sub_repos` | array de strings | `[]` | Caminhos de sub-repositórios aninhados relativos à raiz do projeto. Quando definido, as ferramentas com reconhecimento de GSD limitam a busca de fase, resolução de caminho e operações de commit por sub-repo em vez de tratar o repositório externo como um monorepo |

View File

@@ -352,6 +352,7 @@ API 密钥字段接受字符串值(密钥本身)。也可以设置为哨兵
| 设置 | 类型 | 默认值 | 描述 |
|---------|------|---------|-------------|
| `planning.commit_docs` | boolean | `true` | `.planning/` 文件是否提交到 git |
| `planning.pr_strict` | boolean | `false` | `/gsd-pr-branch` 的过滤模式。`false` 会在 PR 分支中保留 STATE.md、ROADMAP.md、MILESTONES.md、PROJECT.md、REQUIREMENTS.md、milestones/ 等结构性规划状态;`true` 会删除所有 `.planning/` 路径 |
| `planning.search_gitignored` | boolean | `false` | 向大范围搜索添加 `--no-ignore` 以包含 `.planning/` |
| `planning.sub_repos` | string 数组 | `[]` | 相对于项目根目录的嵌套子仓库路径。设置后,GSD 感知工具按子仓库划定阶段查找、路径解析和提交操作的范围,而非将外层仓库视为 monorepo |