Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions .claude/agent-memory/product-owner/MEMORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- [Board field IDs](project_board_field_ids.md) — GraphQL node/field/option IDs for Status/Priority/Awaiting; Priority has NO P0 option, only P1-P4
- [Board state 2026-08-16](project_backlog_board_state_2026_08_16.md) — board had only 1 of 36 open issues before this pass; digest column ≠ board presence
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: project_backlog_board_state_2026_08_16
description: State of the backlog board before/after the first board-writes-only PO pass on 2026-08-16 — only #611 was on the board, 35 issues missing entirely
metadata:
type: project
---

Before this pass, the board (project 1) had exactly **one** card (#611,
P1/Backlog) despite 36 open issues existing. `backlog-digest.sh` derives a
`column` for every open issue regardless of whether it's on the board, which
made this easy to miss without diffing against `gh project item-list`
directly — the digest's presence doesn't imply board presence.

**Why:** board-init/bootstrap work (PRs around #609-611 per recent commits)
created the project and field schema but never did a bulk backlog import —
only the issue that happened to be filed around that time landed on it.

**How to apply:** before trusting "the board is roughly in sync," diff
`gh project item-list` counts against `gh issue list --state open` counts.
Don't assume prior passes kept the board populated.

Several issues surfaced during this pass that are candidates for maintainer
attention but were out of scope (board-writes-only run):
- #520/#393/#571 form a cluster (LOAD_SUPPORT / discharge-gate shadow-price
correctness) — likely worth a combined roadmap push rather than fixing
piecewise.
- #118 (Solax-Growatt MIN-inverter correction) has 29 comments — the busiest
thread in the backlog — and is still unlabeled and un-triaged.
- #96 is the only `analyzed`/Ready for Dev item; everything else needing
Stage 2 analysis is still sitting in Analysis.

See [[project_board_field_ids]] for the GraphQL mechanics used.
32 changes: 32 additions & 0 deletions .claude/agent-memory/product-owner/project_board_field_ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: project_board_field_ids
description: GraphQL node/field/option IDs for the backlog board (project 1) — Status, Priority, Awaiting — and the Priority field's real options
metadata:
type: project
---

Board GraphQL IDs, confirmed working 2026-08-16 via a full-board write pass
(all 36 open issues added, Status/Priority/Awaiting set on every card):

- Project id: `PVT_kwHOACEigM4Bgiwa`
- Status field: `PVTSSF_lAHOACEigM4Bgiwazhfh7Mg` — options: Backlog `f75ad846`,
Analysis `012dae50`, Ready for Dev `456880aa`, In Progress `47fc9ee4`,
In Review `58ad8ead`, Done `98236657`
- Priority field: `PVTSSF_lAHOACEigM4Bgiwazhfh7NQ` — options are **P1 `131c5c2f`,
P2 `107b9947`, P3 `6d4b1494`, P4 `4d153125`. There is no P0 option** —
the backlog skill/task text that references "P0 da61340b" is stale; that
option id does not exist on the live field. Treat P1 as the top tier.
- Awaiting field: `PVTSSF_lAHOACEigM4Bgiwazhfh7Nw` — options: reporter
`71ef723a`, discussion `82098dd9`, upstream `16ca2f41`, analysis `c7538747`

Mutation shape that works: `addProjectV2ItemById(input: {projectId, contentId})`
to add a card (contentId = issue node id from `gh issue list --json id`), then
`updateProjectV2ItemFieldValue(input: {projectId, itemId, fieldId, value:
{singleSelectOptionId}})` per field. Run via
`scripts/gh-agent.sh --as po api graphql -f query='...' -f name=value ...`
from inside the repo checkout (it resolves `.env` via `git rev-parse
--git-common-dir`, so it fails silently with "BESS_PO_TOKEN not set" if run
from a non-repo cwd like a scratch tmpdir).

See [[project_backlog_board_state_2026_08_16]] for what was actually on the
board before/after this pass.
53 changes: 39 additions & 14 deletions .claude/skills/backlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,22 @@ description: Use when acting as the bess-manager Product Owner — reviewing the

You own the product backlog. You face the reporter, drive issues to a
Definition of Ready, order the work, and dispatch implementation — but you
never implement, and you never assign. Implementers pull the top of Ready.
never implement, and you never assign. Implementers pull the top of
*Ready for Dev*.

**The board's columns, exactly** — compared as strings, so casing matters:

Backlog | Analysis | Ready for Dev | In Progress | In Review | Done

Note *Ready for Dev* is the column; the *Definition of Ready* below is the
criteria an item must meet to enter it. Two different things.

**You manage the first five. `Done` is not yours.** The digest reads only
*open* issues, so a closed one vanishes from your evidence entirely — you
cannot derive `Done` and must never try to. It is filled by the board's own
built-in "item closed" workflow and ages out via auto-archive. A card in
`Done` is invisible to you by construction, which is the intended behaviour,
not a gap.

Every pass starts from one command. Do not read issues one by one to build a
picture:
Expand All @@ -26,14 +41,25 @@ Board reads need `PROJECT_NUMBER` set and the board created (deferred —
first board access in `backlog-digest.sh` — that failure is expected, not a
bug to route around.

**One thing to verify the first time a board exists.** No board has ever
existed, so the JSON shape `gh project item-list --format json` uses for a
custom field is unconfirmed; the digest assumes each item carries a top-level
`.priority`. On the first real run, check that `priority` is populated rather
than `null` for an item you have set a priority on. If it is `null`, fix the
jq path in `scripts/backlog-digest.sh` — do not add a fallback that tries
several shapes. A silently-null `priority` disables ranking axis 2 without
any error.
**Board writes go through GraphQL, not `gh project`.** The CLI's
`--owner johanzander` resolution needs `read:org`, which the PO token
deliberately does not have, so `gh project ... --owner johanzander` fails with
`unknown owner type` no matter what project access is granted. Address the
board by node ID instead — verified working for both read and write as the PO:

scripts/gh-agent.sh --as po api graphql -f query='...'
project id PVT_kwHOACEigM4Bgiwa
Priority PVTSSF_lAHOACEigM4Bgiwazhfh7NQ (P0 da61340b, P1 131c5c2f, P2 107b9947)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line (new in this PR) still lists P0 da61340b as a live Priority option:

Priority    PVTSSF_lAHOACEigM4Bgiwazhfh7NQ  (P0 da61340b, P1 131c5c2f, P2 107b9947)

But project_board_field_ids.md, added by this same PR, says the opposite:

Priority field: ... options are P1 131c5c2f, P2 107b9947, P3 6d4b1494, P4 4d153125. There is no P0 option — the backlog skill/task text that references "P0 da61340b" is stale; that option id does not exist on the live field.

This PR is introducing the stale claim into SKILL.md in the same diff that documents it as stale elsewhere. Should be fixed to match the confirmed P1–P4 values before merge.


Read-only queries the maintainer runs — `backlog-digest.sh` — use the plain
`gh project` CLI, which works because that token does have the scope.

**Board shape, confirmed.** `gh project item-list --format json` returns each
custom single-select field as a top-level key on the item — `priority: "P1"` —
alongside `status` (the built-in column) and `content.number` (the join key).
Verified against the real board on 2026-08-16 with issue #611 set to P1, read
back correctly by the digest. The board is project **1** under `johanzander`;
export `PROJECT_NUMBER=1`.

## When to Use

Expand Down Expand Up @@ -90,11 +116,10 @@ always wins** — never trust a card's current position. Act on each mismatch:

| Mismatch | Action |
|---|---|
| card *In progress*, no worktree, no PR | abandoned — move to *Ready*, report it |
| card *In Progress*, no worktree, no PR | abandoned — move to *Ready for Dev*, report it |
| worktree present, no session, no PR | the session died mid-issue. Report it and offer to relaunch; the branch's commits survive. **Never silently relaunch** — a session that died twice is telling you something |
| PR `CONFLICTING` | hand to `sweep-prs` |
| worktree whose PR merged | prune via `sweep-prs` |
| issue closed, card not *Done* | move the card |
| *Analysis*/`reporter` quiet 14 days | nudge once; park to *Backlog* at 28 |
| *Analysis*/`discussion` quiet 14 days | summarise the thread, put the open question to the maintainer |
| open issue, comment activity, no labels | file into *Analysis*, assign a sub-state, apply labels |
Expand All @@ -108,7 +133,7 @@ or PR rot found there to `sweep-prs`.

## Verb: next

Rank Backlog and Ready items in this order:
Rank *Backlog* and *Ready for Dev* items in this order:

1. **User-facing breakage** — `bug` opened by someone other than the
maintainer. A wrong number on a real dashboard outranks everything.
Expand Down Expand Up @@ -136,7 +161,7 @@ fresh `origin/main`.
Serialise, do not stack:

- An item with an unmet `blocked_by` stays put. When the blocker's PR merges,
drop `blocked`, move it to *Ready*, and dispatch fresh.
drop `blocked`, move it to *Ready for Dev*, and dispatch fresh.
- Two items likely to touch the same file are queued, not run concurrently —
the second would eat a merge conflict it did nothing to earn. Predict the
touch-set from the Stage 2 analysis or the issue text. Warn and queue; this
Expand All @@ -152,7 +177,7 @@ prior `@claude-bot analyze` comment already on the issue**. Check this by
reading the issue's comments from the digest (or `gh issue view` if the
digest's comment count needs confirming) — never a local file. This is a
check against the item itself, not a ranking pass: an item entering Analysis
is never a member of the Backlog/Ready list that `next` ranks, so it cannot
is never a member of the Backlog/Ready-for-Dev list that `next` ranks, so it cannot
"rank" into a tier. The no-prior-analyze condition exists because the digest
is a stateless snapshot with no notion of "entering" — without it, an item
that Stage 2 already failed to reach a conclusion on (`needs-human-review`)
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ jobs:
- REQUEST_CHANGES — at least one rule violation or correctness bug
- COMMENT — questions/observations only

**Submitting this review is mandatory in every case,
including when you find nothing wrong.** Finishing the run
without calling `gh pr review` is a failed run, not a clean
one. Silence is indistinguishable from a crash to everything
downstream, and it has a concrete cost: GitHub keeps showing
the LAST explicit verdict, so a prior REQUEST_CHANGES goes on
blocking the merge no matter how many clean runs follow, and
the review can never be cleared by re-running. If the diff is
clean, say so in one line and APPROVE.

5. The summary must include:
- Whether the fix matches the linked issue's root-cause
- Whether the test added would catch a regression
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,17 @@ redundant:
token from the macOS keychain, which is XPC, not network. The same block is
why `git push` emitted `failed to store: 100001` — the credential helper
could not cache the credential, though the push itself still landed.

**When a keychain read does fail, `gh` does not error — it silently falls
back to a token with fewer scopes**, so the symptom is a plain `Forbidden`
on an API call that should have been permitted, and the obvious conclusion
("I lack permission for this operation") is wrong. Run `gh auth status`
first: `✓ Logged in ... (keyring)` plus the expected scope line means the
token is fine and the failure is something else. A `Failed to log in ...
(keyring)` line means you are on the fallback token, and the fix is to
restore keychain access or re-run `gh auth refresh`, NOT to widen anything.
Diagnosed the expensive way: a `PUT .../dismissals` returned `Forbidden`
in-sandbox and `DISMISSED` outside it, same account, same command.
- **`network.allowLocalBinding`** — `podman info` returned *"dial tcp
127.0.0.1:64752: connect: operation not permitted"*. The podman VM is reached
over a local TCP port, so `filesystem.allowRead` and
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/test_backlog_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ def test_conflicting_pr_is_reported_on_its_issue(bin_dir: Path) -> None:
item = digest["items"][0]
assert item["pr"] == 610
assert item["pr_state"] == "CONFLICTING"
assert item["column"] == "In review"
assert item["column"] == "In Review"


def test_issue_matched_by_two_prs_emits_one_item_with_a_scalar_pr(
Expand Down Expand Up @@ -368,7 +368,7 @@ def test_worktree_branch_without_issue_prefix_joins_by_delimited_number(
digest = _run(bin_dir)

item = digest["items"][0]
assert item["column"] == "In progress"
assert item["column"] == "In Progress"
assert item["worktree"] == "/repo/worktrees/wt1"


Expand Down Expand Up @@ -432,7 +432,7 @@ def test_worktree_matched_only_by_similar_number_is_not_joined(bin_dir: Path) ->

digest = _run(bin_dir)

assert digest["items"][0]["column"] != "In progress"
assert digest["items"][0]["column"] != "In Progress"
assert digest["items"][0]["worktree"] is None


Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pytest-cov
httpx
black
ruff
mypy
pyyaml
podman-compose
websockets
86 changes: 86 additions & 0 deletions scripts/backlog-board-init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Create the Product Owner's kanban board, once. Idempotent: if a project with
# this title already exists, print its number and change nothing.
#
# Run this as the MAINTAINER, not as the PO identity. A GitHub user cannot
# create a Project inside another user's account, and the board deliberately
# lives under the maintainer's account — it is their backlog and where they
# will look for it. `scripts/gh-agent.sh --as po project list --owner
# johanzander` returns "unknown owner type" for exactly this reason.
#
# The PO then gets write access to the board as a project collaborator, which
# is a separate grant from repo collaboration — repo write does NOT imply
# project write. That step is manual (see the end of this script's output):
# the Projects v2 API exposes no collaborator-invite mutation.
#
# Usage:
# scripts/backlog-board-init.sh # create or report the board
#
# Output (stdout, last line):
# PROJECT_NUMBER <n>
set -euo pipefail

owner="${PROJECT_OWNER:-johanzander}"
title="BESS Manager Backlog"

# `gh project` needs the `project` scope. Fail with the fix rather than a raw
# GraphQL error — this is the single most common setup failure here.
if ! gh auth status 2>&1 | grep -q "project"; then
echo "backlog-board-init.sh: your gh token lacks the 'project' scope." >&2
echo " Fix: gh auth refresh -s project" >&2
exit 1
fi

existing=$(gh project list --owner "$owner" --format json \
--jq ".projects[] | select(.title == \"$title\") | .number" || true)

if [ -n "$existing" ]; then
echo "Board already exists — nothing changed." >&2
echo "PROJECT_NUMBER $existing"
exit 0
fi

number=$(gh project create --owner "$owner" --title "$title" \
--format json --jq '.number')

echo "Created project #$number." >&2

# Custom fields. The built-in Status field carries the columns and is edited
# separately (see the closing instructions) — `gh` cannot rewrite the options
# of a built-in single-select field.
gh project field-create "$number" --owner "$owner" \
--name "Priority" --data-type SINGLE_SELECT \
--single-select-options "P0,P1,P2" >/dev/null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--single-select-options "P0,P1,P2" hardcodes a 3-option Priority field (P0/P1/P2). Per project_board_field_ids.md (added in this same PR), the confirmed live board's Priority field has P1–P4, with no P0 option at all. If this script is ever run to (re)create the board, it produces a field that doesn't match what the rest of this PR documents as ground truth — including the field IDs referenced in SKILL.md and the memory files.

echo " + Priority field (P0,P1,P2)" >&2

gh project field-create "$number" --owner "$owner" \
--name "Source" --data-type SINGLE_SELECT \
--single-select-options "issue,TODO" >/dev/null
echo " + Source field (issue,TODO)" >&2

gh project field-create "$number" --owner "$owner" \
--name "Awaiting" --data-type SINGLE_SELECT \
--single-select-options "reporter,discussion,upstream,analysis" >/dev/null
echo " + Awaiting field (reporter,discussion,upstream,analysis)" >&2

cat >&2 <<REMAINING

Two steps remain and neither can be scripted — the Projects v2 API exposes no
mutation for either:

1. Columns. Open the board, edit the built-in Status field, and set its
options to exactly:
Backlog, Analysis, Ready for Dev, In Progress, In Review, Done
The digest derives these names; a mismatch silently strands cards.

2. PO access. Project -> ... -> Settings -> Manage access -> invite
bess-product-owner with write. Repo collaboration does NOT grant project
access, so without this every board write fails as the PO.

Then export PROJECT_NUMBER=$number (or add it to your shell profile) and run
scripts/backlog-digest.sh to confirm the board is readable.

REMAINING

echo "PROJECT_NUMBER $number"
42 changes: 28 additions & 14 deletions scripts/backlog-digest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,10 @@ sessions=$(claude agents --json)
# default is 30, which would silently truncate against this repo's 37+ open
# issues.
#
# NOTE: the board does not exist yet (created by a deferred task), so the
# exact JSON key the Priority field lands under is still unverified. The jq
# below assumes it arrives as a top-level `.priority` on each item (matching
# the existing `$board.items[]?.priority?` lookup). Confirm this against a
# real board the first time one exists, and fix the jq path below if the
# assumption is wrong — do not add a fallback that tries multiple shapes.
# Confirmed against the real board (2026-08-16): each custom single-select
# field arrives as a top-level key on the item — `"priority": "P1"` — next to
# `"status"` (the built-in column) and `"content": {"number": 611, ...}`, which
# is the join key used below. No `--field` flag is involved or possible.
board=$(gh project item-list "$PROJECT_NUMBER" --owner "${PROJECT_OWNER:-johanzander}" \
--limit 200 --format json)

Expand Down Expand Up @@ -123,15 +121,31 @@ jq -n \
elif (human_comments($comments) | length) > 0 then "discussion"
else null end;

# Ready keys off the `analyzed` label alone, deliberately. The design also
# requires "Priority is set", but no board exists yet so priority is null for
# everything — gating on it here would make Ready permanently unreachable and
# strand every analysed item. Add the priority condition in the same change
# that creates the board.
# Five derived columns. `Done` is deliberately absent and unreachable here:
# only OPEN issues are fetched, so a closed one leaves the digest entirely
# and cannot be derived into anything. The board fills `Done` itself via its
# built-in item-closed workflow.
#
# These strings must match the Status options on the board EXACTLY — the
# reconcile step compares them as strings, so a casing difference silently
# strands every card. Confirmed against project 1 on 2026-08-16:
# Backlog, Analysis, Ready for Dev, In Progress, In Review, Done
# `Ready for Dev` and `In Progress` follow the wording the board itself uses,
# not the spec wording (`Ready`, `In progress`). The board is authoritative
# for its own column names.
#
# NOTE: no apostrophes in this jq program. It is a single-quoted shell
# string, so one apostrophe silently truncates the whole program and jq
# reports only "Top-level program not given".
#
# Ready for Dev keys off the `analyzed` label alone, deliberately. The design
# also requires Priority to be set, but gating on it here would strand every
# analysed item whose priority the PO has not set yet. Add the priority
# condition only alongside a rule that guarantees one gets set.
def column($labels; $pr; $wt; $awaiting):
if $pr != null then "In review"
elif $wt != null then "In progress"
elif ($labels | index("analyzed")) then "Ready"
if $pr != null then "In Review"
elif $wt != null then "In Progress"
elif ($labels | index("analyzed")) then "Ready for Dev"
elif $awaiting != null then "Analysis"
else "Backlog" end;

Expand Down
Loading
Loading