| name | maintain-project-board |
| description | Maintenance + advance task menu for the 🌊 Project Board (org project 5) — the maintainer's single cross-portfolio navigation surface. Covers coverage, hierarchy, status hygiene, views, and the roadmap axis. Use when the daily maintainer selects the project board on rotation, or when a survey shows board drift. |
Maintain: 🌊 Project Board (org project 5)
github.com/orgs/devantler-tech/projects/5 is a
product, not a byproduct — it is the maintainer's single surface for seeing what exists, what is
moving, and where it is headed across ~20 repos. It gets the same continuous-enhancement treatment as
every other product: it participates in the normal rotation, it has a roadmap, and drift in it is a
defect (maintainer direction 2026-07-18).
Its users are the maintainer and the agent instances. Judge it the way a user would: open it and ask
"can I tell what is happening here?" — not "is the data technically present". A board that is
accurate but unreadable has failed.
Shared cross-repo rules live in the monorepo AGENTS.md — in particular
Issue hierarchy and Every issue belongs on the board, which are binding on every run whatever
product it is working on. This card is the place where the board itself gets improved.
Health checks (the operate half — cheap, every rotation)
Run these as a survey pass; each has a known-good answer.
🔴 Do NOT check the board by enumerating it — an explicit high limit does NOT make enumeration
safe. gh project item-list defaults to --limit 30, but raising the limit only moves the cut:
measured 2026-08-06, --limit 3000 returned exactly 3000 items against a true
projectV2{items{totalCount}} of 4988, with no truncation signal — no warning, no error,
exit 0. ~40% of the board was silently absent, so every check below would have reported clean while
the drift sat past the cut. Returning exactly the limit is the only tell, and it is one the caller
has to look for.
⚠️ Enumeration also costs the whole hourly GraphQL budget, which every lane shares. That same
pass left graphql: {limit:5000, remaining:73}. The budget is attached to the user, so
claude/*, codex/* and cursor/* all draw on it — and the surveyor's paginated reviewThreads
queries are what the hygiene pentad's unresolved-thread count depends on. A starved pentad reads as
clean, which is a fail-open on the promotion gate. Never spend the shared budget on a board
sweep to answer a question a per-issue query answers for ~1 point.
So: ask per issue, not per board. For coverage and membership, query the issue's own side. That
read is cheap and it distinguishes "on no project" from "on the board" (negative control:
platform#1 → [], verified non-vacuous) — but it carries the same three fail-open traps the
enumeration does, so it gets the same discipline:
- Identify the board by its
id, never by number alone. A number is unique only within one
owner, so a repository-level project or another owner's project numbered 5 satisfies a
number-only match and reports an unboarded issue as covered.
- Paginate.
projectItems(first: N) truncates exactly like item-list --limit N, and here the
truncated read produces the worse answer: "not on the board" for an issue that is.
- Fail closed on the read itself. An empty
projectItems and a failed query look identical
downstream. Only a successful query may be read as "not on a project"; anything else is
unverified, which is a different outcome from either answer.
includeArchived: false is explicit because an archived item is not on the board for triage — it is
covered by the archive, not by the sweep, and the default would quietly count it as coverage.
BOARD_ID=$(gh api graphql -f query='{organization(login:"devantler-tech"){projectV2(number:5){id}}}' \
--jq '.data.organization.projectV2.id') \
|| { echo "board id lookup failed; refusing" >&2; exit 1; }
on_board() {
_ob_after=null
while :; do
_ob_page=$(gh api graphql -F owner="$1" -F name="$2" -F number="$3" -F after="$_ob_after" -f query='
query($owner:String!,$name:String!,$number:Int!,$after:String){
repository(owner:$owner,name:$name){ issue(number:$number){
projectItems(first:100, after:$after, includeArchived:false){
nodes{ project{ id } } pageInfo{ hasNextPage endCursor } } } } }') || return 2
printf '%s' "$_ob_page" | jq -e '.data.repository.issue.projectItems' >/dev/null || return 2
if printf '%s' "$_ob_page" | jq -e --arg id "$BOARD_ID" \
'[.data.repository.issue.projectItems.nodes[].project.id] | index($id)' >/dev/null; then
return 0
fi
printf | jq -e >/dev/null \
|| 1
_ob_after=$( | jq -r )
}
(-F is what makes the first page's null a JSON null rather than the string "null"; a later
cursor that happened to look like a number or boolean would be coerced too, and GraphQL would reject
it — an error, so 2, never a wrong answer. index($id) returning 0 for the first item is
truthy to jq -e, which fails only on false and null; do not "fix" that into a length test.)
If a check genuinely needs the whole board, pair the enumeration with a mandatory truncation
guard and treat a trip as a hard failure, never a result:
total=$(gh api graphql -f query='{organization(login:"devantler-tech"){projectV2(number:5){items{totalCount}}}}' \
--jq '.data.organization.projectV2.items.totalCount') \
|| { echo "totalCount query failed; refusing" >&2; exit 1; }
items=$(gh project item-list 5 --owner devantler-tech --format json --limit "$LIMIT") \
|| { echo "item-list failed; refusing" >&2; exit 1; }
n=$(printf '%s' "$items" | jq '.items | length')
for v in "$n" "$total"; do
case "$v" in ''|*[!0-9]*) echo "non-numeric count [$v]; refusing" >&2; exit 1 ;; esac
done
if [ "$n" -eq "$LIMIT" ]; then echo "TRUNCATED at limit ($n); refusing" >&2; exit 1; fi
if [ "$n" -lt "$total" ]; then echo >&2; 1;
(Written as if, not cond && { …; exit 1; }. Measured: the && form is safe mid-script — set -e
exempts a non-final component of an AND-OR list — but on the healthy path it evaluates to 1,
so as the last command of a script or function it returns a spurious failure and takes a set -e
caller down with it. if has no such edge. Set LIMIT above totalCount; the equality arm is what
catches the silent cut.)
🔴 Each gh call is checked on its own line, and both counts are asserted numeric, because
otherwise this guard fails OPEN — the exact direction it exists to prevent. Measured: with gh
returning non-zero, the old one-line pipeline left n empty; [ "" -eq "$LIMIT" ] and
[ "" -lt "$total" ] then both exit 2 with integer expression expected, and a [ failure
inside an if condition is exempt from set -e — so both arms evaluated false, the script reached
its success path, and it exited 0 having counted nothing. A rate limit therefore read as a clean
board. set -o pipefail alone would not have saved it either: the pipeline masks gh's status behind
jq's. Verified in all three states — gh failing ⇒ exit 1, a genuine truncation (n == LIMIT) ⇒
exit 1, and a healthy full read ⇒ exit 0, so the guard is not vacuous.
Never suppress stderr on these calls: a rate-limited gh prints API rate limit exceeded and exits
non-zero, and a 2>/dev/null turns that into an empty result indistinguishable from a clean board.
| Check | Query | Healthy |
|---|
| Coverage | open issues in active public repos vs. items on the board (private-repo items are a maintainer decision, never counted against coverage) | 100% |
| Status hygiene | board items with no Status | 0 |
| Type hygiene | board items whose issue carries no Issue Type | 0 (the contract makes a type mandatory; an untyped item is invisible to Kanban/Roadmap type filters) |
| Hierarchy — migration | open issues with a prose Part of #N but no real parent link | 0 |
| Hierarchy — orphans | open non-Epic issues with no:parent-issue, minus the contract's exemptions (hotfixes, trivial Chores, standalone Spikes) | 0 — the default is that every issue belongs to an Epic, so a growing orphan count means the board is flattening back into a list |
| Hierarchy — undecomposed | type:"Epic" issues with no sub-issues (no:sub-issues-progress) | 0 — an Epic with no children is undecomposed, not finished; decomposing them is high-value advance work (37 existed on 2026-07-18) |
| Dangling parents | Part of references resolving to a PR, to self, or to nothing | 0 |
| Stale epics | epics at Sub-issues progress 100% but still open | 0 (close or extend) |
| Closed root with open work | type:"Epic" issues that are closed yet still have any open descendant | 0 — re-open the Epic (do not re-parent, and do not change the Backlog filter). The Backlog view is is:issue is:open no:parent-issue with Show hierarchy; a closed root fails is:open, so its open children disappear from every expandable tree even though the parent link is correct (#2266). GitHub project filters have no "closed parent with open descendants" qualifier, so the fix is lifecycle, not a fourth view or a filter edit |
Never close an Epic while any descendant is still open. Close the Epic only when its children are done (or explicitly cancelled). If a survey finds a closed Epic with open descendants, re-open it immediately — that is the #2266 resolution, not a Backlog filter change.
Coverage and hierarchy are the two that silently rot, because auto-add is forward-only and capped at
5 workflows on the Team plan — see monorepo#2237.
Until that is solved, backfill is a standing duty, not an exception.
What "advance" means here
-
Views — exactly three, and resist adding a fourth. The board carries a kanban (board), a
backlog (table) and a roadmap (roadmap) view. Epic breakdown lives on the Backlog view via
the Show hierarchy toggle, NOT as a separate view and NOT as a Parent issue group-by —
maintainer direction 2026-07-18, when a proposed fourth "By epic" view was folded into the Backlog
instead. The principle generalises: prefer an extra grouping, slice or filter on an existing view
over a new view.
Every additional view is another surface to keep honest and another place for the maintainer to
look; a board with three well-configured views beats one with seven overlapping ones. Add a fourth
only when a genuine audience or cadence cannot be served by grouping an existing view.
⚠️ Creating a view is scriptable; EDITING one is not. REST documents
POST /orgs/{org}/projectsV2/{project_number}/views with name, layout
(table/board/roadmap), filter and visible_fields — so a new view can be created from the
API. There is no documented PATCH/DELETE for a view, ProjectV2View is a read-only GraphQL
type (no view mutations), and gh project has no view subcommand — so changing an existing view's
filter, layout, grouping or toggles needs a browser with the maintainer's session. (A GET against
the views path 404s on this org, so treat the POST as documented-but-unexercised: verify before
relying on it, and don't create a throwaway view to test — there is no documented way to delete it.)
Propose view edits precisely (layout, filter string, grouping, visible fields) so applying them by
hand is mechanical.
The only browser an agent can DRIVE is Chrome via the Claude extension. Computer-use grants
browsers at read tier only — screenshots work, clicks and typing are blocked at the OS level —
so Safari/Firefox/Arc can be seen but never operated, by design. Don't burn a round trip
requesting browser access for a click-through task; check list_connected_browsers first, and if
nothing is connected, say so and offer the tooltip-guided walkthrough instead. When saving a view,
GitHub prompts "make it the default for everyone" — the board is shared, so every view save is
a public change.
(verified live 2026-07-18):
Mutation safety
updateProjectV2Field with singleSelectOptions replaces the whole option list — always pass the
existing option **id**s or every assignment is destroyed. Verify emoji codepoints after writing
(🫴 Ready is U+1FAF4; a wrong codepoint silently rewrites the option name).
- Adding an item and setting its fields are two separate calls — you cannot do both at once.
- Pace bulk work: ~80 content-generating requests/minute, 500/hour. Serialize; never fan out.
- The board is public. Adding items from private repos is a maintainer decision, not an
agent default.
Roadmap & enhancement
Roadmap lives in GitHub Issues on devantler-tech/monorepo — never enumerated here, because a
hard-coded list goes stale the moment an issue closes or a new one is filed (the same mistake the
retired status-board made). Query it live:
That issue repository also anchors coordination for path-less board/API work: acquire
agent-claim/<issue> against the monorepo root before mutating, retain the returned SHA, atomically
renew the retained SHA immediately before the board mutation with
claim_sha="$(.claude/scripts/agent-claim.sh renew <issue> "$claim_sha" --repo-dir <monorepo-root>)",
and retire that renewed SHA only after the resulting board state is read back and verified. A failed
renew means a takeover won or ownership is unknown, so stand down without mutating. A comment is an
activity record, not an atomic claim.
Board work hangs off the board Epic — monorepo#2261 —
so it is found structurally, never by string matching:
gh api "search/issues?q=org:devantler-tech+is:issue+is:open+parent-issue:devantler-tech/monorepo%232261&per_page=100" \
--jq '.items[] | "#\(.number)\t\(.created_at[0:10])\t\(.title)"'
A text search ("board" in:title,body) was tried and rejected: it misses board issues that don't
contain the word ("Automate stale-item archiving") and pulls in unrelated issues that merely mention a
board. A hard-coded list was rejected before that, for going stale on the first close. File new board
work as a child of #2261 — the contract's default that every issue belongs to an Epic, applied to the
board's own product.
The strategic frame: what can the maintainer still not see at a glance? Answer that, and the board has
advanced.