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
35 changes: 25 additions & 10 deletions .claude/skills/backlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ Open an individual issue only when you are deciding about that issue.
## Prerequisites

The board exists: **Project #1, "BESS Manager Backlog"**. `PROJECT_NUMBER=1`
lives in the repo's `.env`, which `backlog-digest.sh` does **not** source — so
export it first, or the digest exits with a "board has not been created yet"
message that is misleading rather than wrong:

set -a; . ./.env; set +a; ./scripts/backlog-digest.sh
and `BESS_PO_TOKEN` live in the repo's `.env`, and `backlog-digest.sh` sources
it itself — **run it plainly, with no `set -a` preamble**. The file is
gitignored and so exists only in the main checkout; the script resolves it via
`git rev-parse --git-common-dir`, which points there from inside a worktree
too.

An earlier version required the caller to export those first, which is why an
unattended `/loop /backlog` pass died on its first line every time: the thing
invoking the script is a skill, not a shell someone typed into. If the digest
now exits complaining about `PROJECT_NUMBER`, the variable is genuinely absent
from `.env` — do not work around it by exporting one.

Board writes need `BESS_PO_TOKEN` with `project` scope.

Expand Down Expand Up @@ -147,10 +153,16 @@ For each item the digest puts in Backlog or Analysis:
## Verb: board

Reconcile every card against the digest's derived `column`. **The digest
always wins** — never trust a card's current position. Act on each mismatch:
always wins** — never trust a card's current position.

The comparison is in the digest: every item carries `board_status` (where the
card sits now) alongside `column` (where the evidence says it belongs), using
the same six Status strings, so a mismatch is `board_status != column` and
nothing needs a second `gh project item-list` call. Act on each mismatch:

| Mismatch | Action |
|---|---|
| `board_status: null` | the issue has **no card at all**. It carries no Priority, and *Ready for Dev* requires one — so it can never become dispatchable however well it is analysed, while reading as an ordinary Backlog item. Add the card, then triage it normally. Also listed under `orphans` as `issue_no_card` |
| card *In Progress*, no worktree, no PR | abandoned — move to *Ready for Dev*, report it |
| `stale_worktree: true` | the branch already merged; the worktree is rot, not work. Hand to `sweep-prs`, and do not read it as progress |
| worktree present, no session, no PR | the session died mid-issue. The branch's commits survive — resuming is `/implement-issue <n>`, whose Step 0 detects the prior work and re-enters at the right step. **Never silently relaunch**: a session that died twice is telling you something, and a background dispatch that reports `working` may have written nothing at all — verify by work product (`git -C <wt> log`, file mtimes), never by session state |
Expand All @@ -165,9 +177,10 @@ always wins** — never trust a card's current position. Act on each mismatch:
Never auto-park an active conversation, and never chase a reporter for
something an upstream vendor owns.

Also review the digest's `orphans` list (worktrees with no matching open
issue, PRs with no `fixes/closes/resolves` reference) and hand any worktree
or PR rot found there to `sweep-prs`.
Also review the digest's `orphans` list — worktrees with no matching open
issue, PRs with no `fixes/closes/resolves` reference, and open issues with no
board card (`issue_no_card`). Hand worktree or PR rot to `sweep-prs`; a missing
card is yours to add.

## Verb: rhythm — the unattended pass

Expand All @@ -191,7 +204,9 @@ Actions, and who does what:
| Action | Do |
|---|---|
| `resume_implementation` | `/implement-issue <n>`. **The action that produces a ready PR** — Step 11 requests the review, acts on the verdict and runs `gh pr ready`. Covers a draft needing a first review, a rework, an approved PR that never got flipped, *and* a worktree whose session died |
| `awaiting_maintainer` | nothing; report it. Out of draft is the finish line |
| `awaiting_maintainer` | nothing; report it. Out of draft **and carrying an APPROVED review** is the finish line |
| `request_review` | out of draft but Stage 4 never ran. `scripts/request-pr-review.sh <n>`. **Never report an unreviewed PR as ready to merge** — the draft flag is not a review, and a maintainer who flips it because a PR looks stuck routes around the one gate the pipeline is built on |
| `rework_review` | out of draft with changes requested: `/implement-issue <n>` to address them, then a fresh review |
| `resolve_conflict` | hand to `sweep-prs` |
| `recheck_ready` | the reporter answered: re-check Definition of Ready, clear `Awaiting` if satisfied |
| `nudge_reporter` | one nudge, as the PO identity |
Expand Down
171 changes: 168 additions & 3 deletions backend/tests/test_backlog_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,24 +59,44 @@ def _run_expect_failure(bin_dir: Path, **extra_env: str) -> subprocess.Completed
)


def _porcelain(*worktrees: tuple[str, str | None]) -> str:
def _porcelain(*worktrees: tuple[str, str | None], locked: tuple[str, ...] = ()) -> str:
"""Build `git worktree list --porcelain` output for a main checkout
followed by the given (path, branch) pairs. branch=None means detached."""
followed by the given (path, branch) pairs. branch=None means detached.

Paths named in `locked` get a `locked` record in the real shape Claude Code
writes it -- `locked claude session <name> (pid N start ...)`, a reason
string rather than the bare keyword, which a `== "locked"` match would miss.
"""
records = [
"worktree /repo\nHEAD 0000000000000000000000000000000000000\nbranch refs/heads/main"
]
for path, branch in worktrees:
lines = [f"worktree {path}", "HEAD 1111111111111111111111111111111111111"]
lines.append(f"branch refs/heads/{branch}" if branch else "detached")
if path in locked:
lines.append(
"locked claude session wt (pid 97626 start Mon Aug 17 15:50:14 2026)"
)
records.append("\n".join(lines))
return "\n\n".join(records) + "\n"


def _git_shim(porcelain: str) -> str:
def _git_shim(porcelain: str, common_dir: str = "/nonexistent/repo/.git") -> str:
"""A `git` answering the two calls the digest makes.

`rev-parse --git-common-dir` is how the digest locates the repo's `.env`,
and answering it from the shim is what keeps that lookup deterministic: a
test that let the real git through would resolve to the maintainer's
actual checkout and read their real `.env`, so the outcome would depend on
a gitignored file that CI does not have. The default points at a directory
holding no `.env`, which is the "nothing to source" case every pre-existing
test wants.
"""
escaped = porcelain.replace("'", "'\\''")
return f"""
case "$1 $2 $3" in
"worktree list --porcelain") printf '%s' '{escaped}' ;;
"rev-parse --path-format=absolute --git-common-dir") printf '%s\\n' '{common_dir}' ;;
*) echo "unexpected git call: $*" >&2; exit 1 ;;
esac
"""
Expand Down Expand Up @@ -784,3 +804,148 @@ def test_missing_project_number_fails_loudly(bin_dir: Path) -> None:

assert result.returncode != 0
assert "PROJECT_NUMBER" in result.stderr


def _dotenv(bin_dir: Path, tmp_path: Path, contents: str) -> None:
"""Point the git shim at a repo root holding the given `.env`."""
repo_root = tmp_path / "checkout"
repo_root.mkdir(exist_ok=True)
(repo_root / ".env").write_text(contents)
_write_shim(
bin_dir, "git", _git_shim(_porcelain(), common_dir=str(repo_root / ".git"))
)


def test_project_number_is_read_from_dotenv(bin_dir: Path, tmp_path: Path) -> None:
"""The whole point: an unattended pass exports nothing, so the digest has
to find PROJECT_NUMBER itself or it never runs at all. `.env` is
gitignored, so no caller inherits it."""
_write_shim(bin_dir, "gh", _gh_shim([], [], []))
_dotenv(bin_dir, tmp_path, "PROJECT_NUMBER=1\nBESS_PO_TOKEN=tok\n")

env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}")
env.pop("PROJECT_NUMBER", None)
proc = subprocess.run(
["bash", str(SCRIPT)], capture_output=True, text=True, env=env
)

assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout)["counts"]["issues"] == 0


def test_environment_wins_over_dotenv(bin_dir: Path, tmp_path: Path) -> None:
"""An explicit PROJECT_NUMBER must not be overwritten by the file, or the
variable becomes un-overridable and a pinned value silently reads the
maintainer's real board."""
_dotenv(bin_dir, tmp_path, "PROJECT_NUMBER=1\n")
# A `gh` that refuses any project but 7. The assertion is that the digest
# asked for 7, which is observable nowhere except in the call itself.
_write_shim(
bin_dir,
"gh",
'case "$*" in\n'
' *"project item-list 7"*) echo \'{"items": []}\' ;;\n'
' *"project item-list"*) echo "wrong project: $*" >&2; exit 1 ;;\n'
' *"issue list"*|*"pr list"*) echo \'[]\' ;;\n'
' *) echo "unexpected gh call: $*" >&2; exit 1 ;;\n'
"esac\n",
)

digest = _run(bin_dir, PROJECT_NUMBER="7")

assert digest["counts"]["issues"] == 0


def test_a_dotenv_without_the_variable_still_fails_loudly(
bin_dir: Path, tmp_path: Path
) -> None:
"""A `.env` that exists but carries no PROJECT_NUMBER must not be mistaken
for a configured one."""
_write_shim(bin_dir, "gh", _gh_shim([], [], []))
_dotenv(bin_dir, tmp_path, "BESS_PO_TOKEN=tok\n")

result = _run_expect_failure(bin_dir)

assert result.returncode != 0
assert "PROJECT_NUMBER" in result.stderr


def test_board_status_reports_the_cards_current_column(bin_dir: Path) -> None:
"""`column` is where the evidence says the card belongs; `board_status` is
where it actually sits. The board verb reconciles the two, so the digest
has to carry both — otherwise reconciling means a second API call by
hand, which is the one thing this script exists to prevent."""
issue = _issue(602, labels=[{"name": "bug"}])
card = {"content": {"number": 602}, "status": "Ready for Dev", "priority": "P1"}
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [card]))

item = _run(bin_dir)["items"][0]

assert item["board_status"] == "Ready for Dev"
assert item["column"] == "Backlog"


def test_issue_with_no_card_reports_null_status_and_is_an_orphan(
bin_dir: Path,
) -> None:
"""An off-board issue carries no Priority, and `Ready for Dev` requires
one — so it can never become dispatchable however well it is analysed. It
has to be surfaced, not read as a quiet Backlog item."""
issue = _issue(624, labels=[{"name": "bug"}])
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))

digest = _run(bin_dir)

assert digest["items"][0]["board_status"] is None
orphans = [o for o in digest["orphans"] if o["kind"] == "issue_no_card"]
assert [o["ref"] for o in orphans] == ["624"]


def test_a_locked_worktree_is_reported_as_locked(bin_dir: Path) -> None:
"""The lock is the liveness signal `claude agents` cannot provide: it lists
background agents only, so a foreground `/implement-issue` is invisible and
its worktree reads as abandoned."""
issue = _issue(624)
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
_write_shim(
bin_dir,
"git",
_git_shim(
_porcelain(
("/repo/worktrees/issue-624", "fix/issue-624-pwl"),
locked=("/repo/worktrees/issue-624",),
)
),
)

item = _run(bin_dir)["items"][0]

assert item["worktree"] == "/repo/worktrees/issue-624"
assert item["worktree_locked"] is True
# ...and the name-matched session is still null, which is the whole point.
assert item["session"] is None


def test_an_unlocked_worktree_is_reported_as_unlocked(bin_dir: Path) -> None:
issue = _issue(625)
_write_shim(bin_dir, "gh", _gh_shim([issue], [], []))
_write_shim(
bin_dir,
"git",
_git_shim(_porcelain(("/repo/worktrees/issue-625", "fix/issue-625"))),
)

item = _run(bin_dir)["items"][0]

assert item["worktree"] == "/repo/worktrees/issue-625"
assert item["worktree_locked"] is False


def test_issue_with_a_card_is_not_an_orphan(bin_dir: Path) -> None:
issue = _issue(602)
card = {"content": {"number": 602}, "status": "Backlog", "priority": "P1"}
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [card]))

digest = _run(bin_dir)

assert [o for o in digest["orphans"] if o["kind"] == "issue_no_card"] == []
Loading
Loading