diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index cb19156..6930b96 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -174,6 +174,24 @@ under `branches/`. The main repository checkout is an orchestration checkout: use it for status checks, issue creation/claiming, and creating worktrees, but do not edit tracked repository files there. +**Issue → branch → worktree → PR → cleanup.** Every implementation branch is +tied to an issue number so the work is traceable end to end: + +| Stage | Form | +|-------|------| +| Issue | `#123` (claimed with `status:in-progress`) | +| Branch | `(fix\|feat\|docs\|chore)/issue-123-` (review: `review/pr-456-`) | +| Worktree | `branches/fix-issue-123-` (slashes → hyphens) | +| PR | body says `Closes #123` (closes) or `Refs #123` (related) | +| Cleanup | remove remote+local branch + worktree folder; drop `status:in-progress` | + +`scripts/worktree-start` **rejects** implementation branches that are not +issue-linked (use `--allow-unlinked` only for genuine exceptions). When claiming, +post a comment like +`Claimed. Branch: fix/issue-123-. Worktree: branches/fix-issue-123-.` +Gitea has no native issue→branch API field (only a PR's head branch), so this +linkage is enforced by branch name + claim comment + PR body + cleanup. + Branch folders are ignored by git via `branches/`, so dirty work in one issue does not block starting an unrelated issue in a separate branch folder. No LLM may edit another issue's branch folder unless explicitly assigned to that issue. @@ -313,6 +331,32 @@ All mutating attempts — allowed, blocked, failed, or succeeded — are audit-l with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see [`safety-model.md`](safety-model.md)). +## Releases and version tags + +Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while +unstable. Pick the bump by the largest change since the last tag: + +- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish. +- **MINOR** — new MCP tools, new workflow helpers, new config features; + backward-compatible behavior. +- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract. + +Tags are **annotated** (`git tag -a`), created **only from the exact commit on +remote `master`**, **only after the full suite passes**, and carry release notes +referencing the merged PRs/issues. **Never tag** feature branches, dirty +worktrees, unreviewed or self-authored work, or commits not on remote `master`. + +Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`](../skills/llm-project-workflow/templates/release-tag.md)): + +1. `git fetch prgs --prune`. +2. Confirm local `master` equals `prgs/master` (`0 0`) and the tree is clean. +3. Run the full test suite; stop on failure. +4. Review merged issues/PRs since the last tag + (`git log --oneline ..prgs/master`). +5. Choose the version bump. +6. `git tag -a prgs/master -m ""`. +7. `git push prgs `; add release notes if the forge supports it. + ## Safety notes - Never place raw tokens or passwords in any LLM MCP config; reference secrets diff --git a/scripts/worktree-start b/scripts/worktree-start index ebd683a..cd90992 100755 --- a/scripts/worktree-start +++ b/scripts/worktree-start @@ -3,21 +3,32 @@ set -euo pipefail usage() { cat <<'EOF' -usage: scripts/worktree-start [--dry-run] [start-ref] +usage: scripts/worktree-start [--dry-run] [--allow-unlinked] [start-ref] -Create an issue-specific git worktree under branches/. +Create an issue-linked git worktree under branches/. + +Branch names must be traceable to an issue (or a PR, for review branches): + implementation: (fix|feat|docs|chore)/issue-- + review: review/pr-- +Use --allow-unlinked to bypass the check (discouraged). Examples: scripts/worktree-start fix/issue-123-example - scripts/worktree-start --dry-run review/pr-123-scope-check prgs/master + scripts/worktree-start --dry-run review/pr-456-scope-check prgs/master EOF } dry_run=0 -if [[ "${1:-}" == "--dry-run" ]]; then - dry_run=1 +allow_unlinked=0 +while [[ "${1:-}" == --* ]]; do + case "$1" in + --dry-run) dry_run=1 ;; + --allow-unlinked) allow_unlinked=1 ;; + --help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac shift -fi +done if [[ $# -lt 1 || $# -gt 2 ]]; then usage >&2 @@ -27,6 +38,26 @@ fi branch="$1" start_ref="${2:-prgs/master}" +# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR). +if [[ "$allow_unlinked" -eq 0 ]]; then + if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \ + || [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then + : + else + cat >&2 <- +Review branches: + review/pr-- + +Fix the branch name, or pass --allow-unlinked to override (discouraged). +EOF + exit 2 + fi +fi + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$script_dir/.." && pwd)" worktree_name="${branch//\//-}" diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index f82a8af..1aa90c4 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -34,16 +34,40 @@ assigned to that issue and cleanup is part of the task. ## Branch Naming -Use issue- or PR-scoped names: +Every implementation branch **must include its issue number** so it is +traceable end to end: **issue → branch → worktree folder → PR → cleanup.** + +Allowed implementation patterns: - `fix/issue-123-short-description` - `feat/issue-123-short-description` - `docs/issue-123-short-description` -- `review/pr-456-scope-check` +- `chore/issue-123-short-description` + +Review-only branches: + +- `review/pr-456-short-description` Use a filesystem-safe folder under `branches/` by replacing slashes with hyphens, for example `branches/fix-issue-123-short-description`. +`scripts/worktree-start` **enforces** this: it rejects an implementation branch +that does not match `(fix|feat|docs|chore)/issue--…` (or a +`review/pr--…` branch), unless `--allow-unlinked` is passed. Traceability +is maintained by: + +- the branch name (contains the issue number), +- a claim comment on the issue, e.g. + `Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`, +- the PR body — `Closes #123` when the PR should close the issue, `Refs #123` + when related but not closing, +- cleanup after merge — remove the remote branch, local branch, and the issue + worktree folder, and drop `status:in-progress`. + +Forges like Gitea do not expose a native issue→branch association through the +API (only a PR's head branch), so linkage is enforced by the branch name, claim +comment, PR body, and cleanup process above — not a dedicated API field. + ## Worktree Creation Generic pattern: @@ -221,5 +245,37 @@ Ready-to-copy templates live in `templates/`: - `review-pr.md` - `merge-pr.md` - `recover-dirty-worktree.md` +- `release-tag.md` Adapt the placeholders to the project before use. + +## Versioning And Tagging + +Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while +unstable). Choose the bump by the largest change since the last tag: + +- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish. +- **MINOR** — new tools/helpers/config features; backward-compatible behavior. +- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract. + +Tags must: + +- be created **only from `master`** (the exact commit on remote `master`), +- be created **only after the full test suite passes**, +- be **annotated** tags (`git tag -a`), never lightweight, +- include release notes / a changelog summary referencing the merged PRs/issues. + +**Never tag** feature branches, dirty worktrees, unreviewed or self-authored +work, or commits not present on remote `master`. + +Release process (see [`templates/release-tag.md`](templates/release-tag.md)): + +1. `git fetch --prune`. +2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean. +3. Run the full test suite; stop on any failure. +4. Inspect merged issues/PRs since the last tag + (`git log --oneline ../master`). +5. Choose the version bump. +6. Create the annotated tag on remote `master` with release notes. +7. Push the tag. +8. Create/update release notes if the forge supports it. diff --git a/skills/llm-project-workflow/templates/release-tag.md b/skills/llm-project-workflow/templates/release-tag.md new file mode 100644 index 0000000..895695c --- /dev/null +++ b/skills/llm-project-workflow/templates/release-tag.md @@ -0,0 +1,41 @@ +# Template: cut a release tag + +Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is +irreversible-ish and outward-facing — fail closed on any doubt. + +```text +Task: cut release from master. + +Rules (llm-project-workflow — versioning & tagging): +- SemVer: vMAJOR.MINOR.PATCH (v0.x.y while unstable). + - PATCH: bug fixes, docs, tests, wrappers, non-breaking workflow polish. + - MINOR: new tools/helpers/config features, backward-compatible behavior. + - MAJOR: breaking config/schema/API or changed MCP contract. +- Tag ONLY from clean, tested remote master. Annotated tags only (git tag -a). +- NEVER tag: feature branches, dirty worktrees, unreviewed/self-authored work, + or commits not present on remote master. + +Steps: +1. git fetch --prune +2. Confirm local master == /master (git rev-list --left-right --count + /master...master → 0 0) and the tree is clean. +3. Run the FULL test suite; it must pass. STOP on any failure. +4. Inspect merged issues/PRs since the last tag: + git log --oneline ../master +5. Choose the bump (PATCH/MINOR/MAJOR) per the rules above; set . +6. Create an ANNOTATED tag on /master with release notes that reference + the merged PRs/issues: + git tag -a /master -m ": + + - # (PR #<pr>) + - ..." +7. Push the tag: git push <remote> <vX.Y.Z> +8. Create/update the release notes / changelog entry if the forge supports it. + +Fail-closed: STOP if tests fail, the tree/worktree is dirty, master != remote, +the target commit is not on remote master, or the work was self-authored/ +unreviewed. Never tag to "fix" a failing state. + +Handoff: version, bump rationale, commit tagged, tests result, tag pushed, +release notes link. +``` diff --git a/tests/test_worktrees.py b/tests/test_worktrees.py index 2860067..34d89a9 100644 --- a/tests/test_worktrees.py +++ b/tests/test_worktrees.py @@ -35,16 +35,43 @@ class TestWorktreeStart(unittest.TestCase): self.assertEqual(rc, 2) def test_refuses_existing_worktree(self): - slug = f"zz-refuse-start-{os.getpid()}" + branch = f"fix/issue-999-refuse-{os.getpid()}" + slug = branch.replace("/", "-") target = BRANCHES / slug target.mkdir(parents=True, exist_ok=True) try: - rc, _, err = run("worktree-start", "--dry-run", slug) + rc, _, err = run("worktree-start", "--dry-run", branch) self.assertEqual(rc, 1) self.assertIn("Refusing to reuse", err) finally: target.rmdir() + # -- issue-linked branch validation (#48) -------------------------------- + + def test_accepts_issue_linked_impl_branches(self): + for branch in ("fix/issue-123-example", "feat/issue-123-example", + "docs/issue-123-example", "chore/issue-123-example"): + rc, out, err = run("worktree-start", "--dry-run", branch) + self.assertEqual(rc, 0, f"{branch}: {err}") + self.assertIn(f"branches/{branch.replace('/', '-')}", out) + + def test_accepts_review_branch(self): + rc, out, _ = run("worktree-start", "--dry-run", "review/pr-456-example") + self.assertEqual(rc, 0) + self.assertIn("branches/review-pr-456-example", out) + + def test_rejects_untraceable_branches(self): + for branch in ("fix/random-name", "my-branch", "feat/no-issue-here", + "fix/issue-abc-x"): + rc, _, err = run("worktree-start", "--dry-run", branch) + self.assertEqual(rc, 2, branch) + self.assertIn("Untraceable branch name", err) + + def test_allow_unlinked_override(self): + rc, out, _ = run("worktree-start", "--dry-run", "--allow-unlinked", "my-branch") + self.assertEqual(rc, 0) + self.assertIn("branches/my-branch", out) + class TestWorktreeReview(unittest.TestCase):