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
9 changes: 5 additions & 4 deletions .claude/hooks/block_manual_branch_creation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""PreToolUse hook (Bash): blocks manual branch creation that bypasses scripts/start-group.sh."""
"""PreToolUse hook (Bash): blocks manual branch creation that bypasses scripts/flow.py."""
import json
import re
import sys
Expand Down Expand Up @@ -36,9 +36,10 @@ def main():
"permissionDecisionReason": (
f"Blocked: manual branch creation with name '{name}', which doesn't "
"match <Service>/<type>/Issue-<n>/<description>. Branches must be "
"created via `scripts/start-group.sh <change-name> <group-number>` "
"(docs/development/WORKFLOW.md) so the sub-issue gets created and "
"linked. Do not skip it, even for small/chore work."
"created via `scripts/flow.py start <change-name> <group-number>` "
"(docs/development/WORKFLOW.md) so the group's sub-issue is linked "
"and both it and the parent move to In Progress. Do not skip it, "
"even for small/chore work."
),
}
}
Expand Down
6 changes: 4 additions & 2 deletions .claude/skills/commit-push-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Commit the changes the user has already staged, push the current branch, and ope
Run `gh pr view --json url,number,state 2>/dev/null` (or `gh pr list --head <branch> --json url,number`) to see if a PR already exists for this branch.

- If one exists: report its URL, do not create a duplicate.
- If none exists: gather the branch's full commit history vs. the base branch (`git log <base>..HEAD`, `git diff <base>...HEAD`) and create one with `gh pr create`, using a HEREDOC body:
- If none exists: gather the branch's full commit history vs. the base branch (`git log <base>..HEAD`, `git diff <base>...HEAD`), run `scripts/flow.py pr` to get the issue-closing trailer for this branch, and create the PR with `gh pr create`, using a HEREDOC body:
```
## Summary
<1-3 bullets>
Expand All @@ -56,8 +56,10 @@ Commit the changes the user has already staged, push the current branch, and ope
<checklist>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<the `Closes #...` lines printed by scripts/flow.py pr>
```
Keep the title under ~70 characters.
Keep the title under ~70 characters. Use the trailer exactly as printed — it emits `Closes #<parent>` alongside `Closes #<sub-issue>` only when this is the last open group, and that is what closes the parent tracking issue and fires the board's Done automation. If `flow.py pr` errors (e.g. the branch isn't a task-group branch), say so and open the PR without a trailer rather than guessing issue numbers.

6. **Report**

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/branch-naming.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ jobs:
# <Service>/<type>/Issue-<sub-issue>/<description>, see docs/development/WORKFLOW.md §2
if [[ ! "$BRANCH" =~ ^[A-Z][A-Za-z]*/(feat|fix|chore|refactor)/Issue-[0-9]+/.+$ ]]; then
echo "::error::Branch '$BRANCH' doesn't match <Service>/<type>/Issue-<n>/<description>."
echo "::error::Create branches with scripts/start-group.sh — see docs/development/WORKFLOW.md"
echo "::error::Create branches with scripts/flow.py start — see docs/development/WORKFLOW.md"
exit 1
fi
53 changes: 53 additions & 0 deletions .github/workflows/tooling.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Tooling CI

on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
changes:
runs-on: ubuntu-latest
outputs:
relevant: ${{ steps.filter.outputs.relevant }}
steps:
- uses: actions/checkout@v4
- id: filter
uses: dorny/paths-filter@v3
with:
filters: |
relevant:
- "scripts/**"

tooling-test:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest black flake8

- name: Lint
run: |
black --check scripts
flake8 --max-line-length=100 scripts

- name: Run tests
run: python -m pytest scripts/test_flow.py -v
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
54 changes: 41 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,58 @@
# Working in this repo

## Branch / issue workflow — mandatory
## OpenSpec → issue → branch → PR — mandatory

Every OpenSpec task group gets its own branch and its own GitHub sub-issue,
created together by one command. Full details: `docs/development/WORKFLOW.md`.
One OpenSpec change = one GitHub parent issue. One task group = one sub-issue =
one branch = one PR. All of it runs through `scripts/flow.py`. Full details:
`docs/development/WORKFLOW.md`.

**Never create a branch with `git checkout -b` / `git switch -c` / `git branch` by
hand.** Always run:
There are three moments:

**1. After proposing a change** — create the tracking issues:

```
scripts/flow.py init <change-name> --title "<parent issue title>"
```

Creates the parent issue, folds its number into the change directory name
(`<service>-<type>-<issue>-<description>`), creates a sub-issue for **every**
task group up front, and writes the generated Tracking block into `tasks.md`.
Run it on `main` and commit the result before starting any group.

If `tasks.md` gains, loses, or renames a group later, re-run
`scripts/flow.py sync <change-name>` on `main` and commit that too.

**2. When starting a task group** — never create the branch by hand:

```
scripts/flow.py start <change-name> <group-number>
```

Moves the parent and that group to **In Progress**, then branches from a fresh
`main` as `<Service>/<type>/Issue-<sub-issue>/<description>-group<N>`. Pass
`--base <ref>` to branch from something other than `main`.

**Never** use `git checkout -b` / `git switch -c` / `git branch` directly.

**3. When opening the PR** — get the closing trailer from the branch:

```
scripts/start-group.sh <change-name> <group-number>
scripts/flow.py pr
```

This creates the sub-issue, links it under the parent issue, adds it to the
project board, and checks out a correctly-named branch:
`<Service>/<type>/Issue-<sub-issue>/<description>`.
Prints `Closes #<sub-issue>`, plus `Closes #<parent>` when every sibling group
is already closed. Put those lines at the end of the PR body — that is what
fires the board's Done automation.

This is enforced, not just documented:
- `.claude/hooks/block_manual_branch_creation.py` (a `PreToolUse` hook on `Bash`)
blocks manually-created branches whose name doesn't match the convention.
- `scripts/git-hooks/pre-push` refuses to push a branch with a non-conforming
name (requires `git config core.hooksPath scripts/git-hooks` — see
WORKFLOW.md §6, which also covers this hook's lint-mirroring behavior).
WORKFLOW.md §7, which also covers this hook's lint-mirroring behavior).
- `.github/workflows/branch-naming.yml` re-checks the branch name on every
push and PR as a backstop.

If a task genuinely doesn't fit an OpenSpec change/group (rare — e.g. a
one-off doc fix), ask the user before improvising a branch name outside the
convention.
For a ticket that isn't part of an OpenSpec change, use
`scripts/flow.py issue "<title>" "<body>"`. If such a task still needs a branch,
ask the user before improvising a name outside the convention.
170 changes: 121 additions & 49 deletions docs/development/WORKFLOW.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
# OpenSpec → Issue → Branch Workflow
# OpenSpec → Issue → Branch → PR Workflow

How a piece of work moves from an OpenSpec change to merged code, and how the
naming stays consistent end to end.
naming and tracking stay consistent end to end.

Everything below is driven by one tool, `scripts/flow.py`:

| Command | When | What it does |
| --- | --- | --- |
| `flow.py init <change> --title T` | right after proposing | parent issue + directory rename + a sub-issue per task group + Tracking block |
| `flow.py sync <change>` | whenever `tasks.md` groups change | idempotent reconcile of groups ↔ sub-issues |
| `flow.py start <change> <group>` | starting a group | parent + group → In Progress, branch from `main` |
| `flow.py pr` | opening the PR | prints the `Closes` trailer |
| `flow.py issue "<t>" "<body>"` | one-off ticket | issue on the board, outside any change |

Sub-issues are matched to task groups by the issue number recorded in `tasks.md`,
falling back to the `(group N)` title suffix when no number is recorded.
New issue numbers are saved before linking or updating the board, so `sync`
can resume those steps after a failure without creating another ticket.

## 1. OpenSpec change naming

Expand All @@ -15,86 +30,143 @@ Change directories (`openspec/changes/<name>/`) are a flat identifier to the
Example: `shared-feat-292-audit-mixin-auto-population`

- **service** — lowercase, one of: `ai`, `backend`, `budget`, `chat`,
`feature`, `frontend`, `platform`, `shared`, `users` (same set used in
branch names, see below).
`feature`, `frontend`, `infra`, `platform`, `shared`, `users` (the `SERVICES`
map in `scripts/flow.py`; same set used in branch names, see §3).
- **type** — `feat` | `fix` | `chore` | `refactor`, matching the commit
prefixes already used in this repo (not GitHub's `bug` label).
- `feat` — new capability
- `fix` — bug fix
- `chore` — infra/tooling/deps, no behavior change users would notice
- `refactor` — structural change, no behavior change
- **issue** — the GitHub issue number of the **parent** issue for this
whole change. Its only job is to hold the per-group sub-issues (§3) — it
doesn't carry its own task list.
- **issue** — the GitHub issue number of the **parent** issue for this whole
change. Its only job is to hold the per-group sub-issues (§2) — it doesn't
carry its own task list.
- **description** — short kebab-case summary.

**Ordering with the issue:** a change is often proposed before a ticket
exists. Flow is:
## 2. Creating the change's issues

A change is proposed before any ticket exists, so:

1. `/opsx:propose` with a name that has **no** issue number yet:
`<service>-<type>-<description>`.
2. Once `tasks.md` is real, on `main`:

```
scripts/flow.py init <service>-<type>-<description> --title "<parent title>"
```

which, in one step:
- creates the parent tracking issue and adds it to project board 8 as **Todo**;
- `git mv`s the change directory to fold in the new issue number;
- creates a sub-issue for **every** group in `tasks.md`, links each one under
the parent through GitHub's native sub-issues API (so the parent shows a
progress bar), and adds each to the board;
- writes the group's number back onto its header (`## N. Title — Issue #<n>`)
and generates the Tracking block at the top of `tasks.md`.
3. Commit the rename and the `tasks.md` edits **on `main`** before starting any
group. This is the whole point: the group↔issue mapping must exist on `main`,
not only on a feature branch.

1. `/opsx:propose` with a temporary kebab-case name (no issue number yet).
2. Once `tasks.md` exists and the scope is real, create the parent ticket:
`scripts/new-issue.sh "<title>" "<body>"`.
3. Rename the change directory to fold in the issue number:
`mv openspec/changes/{<temp-name>,<service>-<type>-<issue>-<description>}`.
Sub-issue titles are `<description>: <group title> (group N)`, with any
`— depends on N` planning suffix stripped.

## 2. Branch naming
If groups are added, removed or renamed afterwards, re-run
`scripts/flow.py sync <change-name>` on `main` and commit it. `sync` creates
what's missing, links a recorded issue if it is still unlinked,
refreshes the Tracking block and the parent issue's group list, and warns about
duplicates and orphaned sub-issues instead of silently papering over them.
If a recorded issue belongs to another parent, linking fails and `sync` stops
without moving it or creating a replacement.

### The Tracking block

`tasks.md` carries a generated block between `<!-- flow:tracking:start -->` and
`<!-- flow:tracking:end -->`, listing the parent issue, the board, and a
group → sub-issue → branch → state table. It is rewritten wholesale by `init`
and `sync` — don't hand-edit it.

## 3. Branch naming

```
<Service>/<type>/Issue-<sub-issue>/<description>[-group<N>]
<Service>/<type>/Issue-<sub-issue>/<description>-group<N>
```

`Service` is title-cased (`Shared`, `Budget`, `Frontend`, `AI`, ...); `type`
stays lowercase, taken straight from the OpenSpec change name. `<sub-issue>`
is that **group's own** sub-issue number (§3), not the parent's — e.g.
stays lowercase, taken straight from the OpenSpec change name. `<sub-issue>` is
that **group's own** sub-issue number, not the parent's — e.g.
`Shared/feat/Issue-301/audit-mixin-auto-population-group2`.

## 3. Task groups get their own sub-issue
## 4. Starting a task group

```
scripts/flow.py start <change-name> <group-number>
```

Each `tasks.md` group is tracked as a real GitHub sub-issue of the parent
(GitHub's native sub-issue relationship, not just a text mention) — created
automatically the first time a group is started via `scripts/start-group.sh`:
- Moves the **parent** issue to In Progress (unless it's already Done) and the
**group's** sub-issue to In Progress.
- Fetches `origin/main`, fast-forwards, and branches from it. Use
`--base <ref>` to branch from something else.
- Refuses if the working tree is dirty, if the branch already exists, or if the
group has no sub-issue recorded in `tasks.md` on the current checkout — in
that last case it tells you to run `sync` on `main` and commit first, rather
than creating a duplicate issue.

- Title: `<description>: <group title> (group N)`.
- Body links back to the parent issue and the group's `tasks.md` section.
- Linked to the parent through GitHub's sub-issues API, so it shows up as a
checklist/progress bar on the parent issue.
- Added to project board 8.
- The sub-issue number is written back onto the group's header line in
`tasks.md` (`## N. Title — Issue #<sub-issue>`), so re-running the script
for the same group reuses it instead of creating a duplicate.
## 5. Opening the PR

## 4. Triggering a task group's branch
```
scripts/flow.py pr
```

Don't rely on remembering to create the sub-issue and branch correctly at
each group boundary — run:
Reads the sub-issue out of the current branch name, finds the change and group
that own it, and prints:

```
scripts/start-group.sh <change-name> <group-number>
Closes #<sub-issue>
```

First run for a group: creates its sub-issue (as in §3), links it under the
parent, and derives the branch from it. Later runs for the same group: reads
the sub-issue back out of `tasks.md` instead of recreating it. Either way it
creates and checks out the branch and flips the sub-issue's project item to
**In Progress**. Refuses to run if the change name doesn't parse or the
group doesn't exist in `tasks.md`.
plus `Closes #<parent>` when every other group's sub-issue is already closed —
so the parent closes itself with the last group's PR instead of relying on
someone remembering. Paste the output at the end of the PR body; it fires the
board's Done automation for each issue named.

## 5. PR / issue-closing convention
## 6. One-off tickets

Each group's PR closes its own sub-issue: `Closes #<sub-issue>` (fires the
board's Done automation for that item). Once every sub-issue under the
parent is closed, close the parent too — it's just a tracking issue at that
point.
For work that isn't an OpenSpec change (a doc fix, a bug found in passing):

## 6. Local lint enforcement before push
```
scripts/flow.py issue "<title>" "<body>"
```

Creates the issue and puts it on the board as Todo. If it needs a branch, agree
a name with a human first — the convention above assumes a sub-issue exists.

## 7. Local lint enforcement before push

`scripts/git-hooks/pre-push` mirrors each service's CI lint step
(`black --check`, `mypy`, `flake8`) locally, scoped to whichever
service(s) the push actually touches (a `shared/` change checks all four).
One-time setup:
(`black --check`, `mypy`, `flake8`) locally, scoped to whichever service(s) the
push actually touches (a `shared/` change checks all four). A push touching
`scripts/*.py` runs `black --check`, `flake8 --max-line-length=100` and
`scripts/test_flow.py` over `scripts/` — the workflow tooling lints itself. The
hook also rejects a branch whose name doesn't match §3. One-time setup:

```
git config core.hooksPath scripts/git-hooks
```

Bypass with `git push --no-verify` when intentionally needed.

## 8. Testing the tool itself

`scripts/test_flow.py` covers the change-name/group parsing and the `tasks.md`
rewriting — the load-bearing, network-free parts:

```
python3 -m pytest scripts/test_flow.py -q
```

`.github/workflows/tooling.yml` runs the same tests plus `black`/`flake8` over
`scripts/` on any push or PR that touches `scripts/**`, and the pre-push hook
(§7) mirrors it locally.

`scripts/flow.py --dry-run <subcommand> ...` prints every mutating `gh`/`git`
call instead of running it, which is the safe way to preview `init` or `sync`.
Loading
Loading