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
all registered writer namespaces 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.
# Resolved once per run; a number alone is not an identity.
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; }
# 0 = on the board, 1 = verified absent, 2 = UNVERIFIED (never conflate 1 and 2).
on_board() { # on_board <owner> <repo> <issue-number>
_ob_after=null # prefixed: the function assigns in the caller's scope
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 '%s' "$_ob_page" | jq -e '.data.repository.issue.projectItems.pageInfo.hasNextPage' >/dev/null \
|| return 1
_ob_after=$(printf '%s' "$_ob_page" | jq -r '.data.repository.issue.projectItems.pageInfo.endCursor')
done
}
(-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 "INCOMPLETE: $n of $total; refusing" >&2; exit 1; fi
(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 |
| Reopened stuck at Done |
open issues whose Status is still โ
Done |
0 โ closedโDone is a built-in workflow but nothing moves an issue back out on reopen, and the Kanban filters -status:"โ
Done", so reopened work vanishes from the main view exactly when it needs attention. Move it back to the state that matches reality (0 on 2026-07-18 โ latent, not live) |
| Archive |
closed/merged items still active on the board |
none older than 30 days closed โ except any item whose hierarchy still contains open work, in either direction: a closed item with any still-open ancestor (a closed sub-Epic under an open top-level Epic keeps its own closed children too), and a closed item with any still-open descendant (an Epic closed prematurely while a child is still open must stay visible, or the open child is orphaned from a tree nobody can see). These stay active. Archiving it removes it from the Backlog's hierarchy, so the decomposition becomes unreadable while the progress bar still counts it, and "what is part of what" (the whole point of the board) silently loses rows. Younger items also stay active so Insights keeps recent history โ see the Insights trap below; manual until #2238's auto-archive works |
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.
Current configuration (verified live 2026-07-18):
| View |
Layout |
Filter |
| ๐งฎ Kanban |
Board |
is:issue is:open -status:"โ
Done" -type:"Epic" |
| ๐ Backlog |
Table |
is:issue is:open no:parent-issue |
| ๐บ๏ธ Roadmap |
Roadmap |
is:issue is:open type:"Epic" no:parent-issue |
Keep these three filters. #2266
asked whether open work under a closed root should be fixed by changing the Backlog filter,
re-opening the root, or re-parenting. Decision (2026-07-22): re-open the root (and never close
an Epic with open descendants). A filter change cannot express that case, a fourth view would
violate the three-view rule, and re-parenting destroys the recorded decomposition. No maintainer
UI edit is required for this decision.
Each view shows one layer of the hierarchy, and only one (maintainer direction 2026-07-18):
Kanban = actionable work only โ a parent is resolved by finishing its children, so an epic is
never something you drag across a board; Backlog = top-level only (no:parent-issue) with
children reached by expanding the hierarchy, never listed as sibling rows; Roadmap = top-level
Epics.
โ ๏ธ Filter on type:"Epic", NOT on no:sub-issues-progress or label:roadmap. The structural
filter only excludes epics that have already been decomposed โ 37 undecomposed epics were
sitting on the Kanban looking like actionable work, because an epic nobody has broken down yet has no
children to detect. Switching to -type:"Epic" dropped the Kanban 166 โ 130 and is correct from the
moment an issue is filed. The label is likewise unreliable: it depends on label hygiene, and Epic
as a type is a superset (62 epics vs 55 roadmap-labelled).
The board tracks ISSUES, not PRs (maintainer direction 2026-07-18): a PR that closes an issue
stands in for that issue's progress, so showing both double-counts the same work โ dropping ,pr
took the Ready column from 8/5 to a true 2/5. โ ๏ธ No automation moves an issue card from its
linked PR's state โ native Project workflows act only on the project's own items, and nothing in
this org updates an issue's Status from PR events. **The agent working the PR moves the issue's
Status by hand at each lifecycle step, when that step's entry condition in AGENTS.md actually
holds โ a PR merely being open is still ๐๐ปโโ๏ธ In Progress; it becomes ๐ In Review only once CI is
green and a review has been requested, and ๐ Ready to Merge only on a green review at head with
the pentad clear. Moving a card early makes the board overstate progress, which is the one thing a
status ladder must never do. Only the final closedโDone move is a built-in workflow.
โ ๏ธ ๐ Verifying belongs on the issue that is still OPEN, not on the one the merge just closed.
A delivery PR carries Fixes #delivery, so merging closes that issue and the built-in
closedโDone workflow marks it โ
Done โ moving it to Verifying would either be immediately
overwritten or leave finished work looking pending. When the outcome cannot be known at merge, the
PR also carries Part of #experiment; that experiment/Kata issue stays open, and it is the one
that goes to ๐ Verifying until its measurement date, when it becomes โ
Done with the decision
recorded. If a change has no separate experiment issue but still needs a post-merge check, keep the
delivery issue open (drop Fixes, use Part of) rather than closing it and reopening it. This also makes
Fixes #N load-bearing for visibility, not just bookkeeping: an agent-authored PR with no linked
issue is now invisible on the board, so the capture-before-you-build rule is what keeps the board
complete. When a substantive PR has no issue, the fix is to file/link the issue โ never to put PRs
back on the board. Dependency-automation PRs correctly disappear: they are automation-owned and
need no action, so they were only ever noise on a planning surface.
Backlog has Show hierarchy = On, which nests sub-issues under their parents inline (up to 8
levels) โ that is what makes it the by-epic view; do not add a Parent issue group-by on top, and do
not turn the toggle off. Roadmap binds Start/Target to the Year iteration's Year start/Year end, zoom Year, with the Year marker on; items without a Year show an unscheduled + placeholder,
which is the honest rendering of "not scheduled".
Hierarchy filters โ use the FIELD name, and get it from the UI, not from docs. The working
qualifiers are has:sub-issues-progress (is a parent) / no:sub-issues-progress (is a leaf),
and has:parent-issue / no:parent-issue / parent-issue:owner/repo#N. They key off the project
field name ("Sub-issues progress"), not the issue-search spelling: has:sub-issue is NOT a
valid project filter โ GitHub silently ignores an unrecognised qualifier rather than erroring, so a
wrong guess looks like it worked.
โ ๏ธ Two method rules, learned the hard way on 2026-07-18:
- Never compare item counts across views to test a filter. Board and table layouts count
differently โ the same stricter filter read 193 on the board and 163 in the table. A cross-view
comparison produced a confidently wrong conclusion (that
has:sub-issue was being ignored and
that no parent filter existed). Always A/B a filter within ONE view.
- Type
no: into the filter box to get the authoritative qualifier list. GitHub autocompletes
every supported field; picking from that list inserts the exact syntax. That is faster and more
reliable than docs, which do not enumerate project-field qualifiers.
Status semantics. Options run โ
Done โ ๐ Verifying โ ๐ Ready to Merge โ ๐ In Review โ
๐๐ปโโ๏ธ In Progress โ ๐ซด Ready โ ๐ฅ Backlog โ ๐ง Icebox, and the merge is the boundary between
Ready to Merge (pre-merge, mechanical) and Verifying (post-merge, evidential). The order is
deliberately reversed โ finishing work sits leftmost, so the board reads stop starting, start
finishing (maintainer direction 2026-07-18). Never re-order it into left-to-right flow. Column
limits encode the same WIP discipline; treat an over-limit column as a signal to finish, never as
a reason to raise the limit. "Blocked" is intentionally not a status โ use a native issue
dependency (Blocked badge renders in-place) and reserve the blocked label for cross-org blockers.
Issue Types are mandatory โ every issue carries exactly one of Epic, Feature, Bug, Security,
Performance, Refactor, Docs, Spike, Kata, Chore (see the contract's Issue hierarchy for the
definition-of-done each implies). An untyped issue is an incomplete issue. Setting one always works,
but always name the repo โ a bare number resolves in the current repo, so triaging another
repo's item from the monorepo would type the same-numbered monorepo issue instead:
gh issue edit <N> --repo devantler-tech/<repo> --type "<Type>" (or pass the issue URL). Managing
the org type list is a REST surface โ collection GET/POST /orgs/{org}/issue-types, then
item-specific PUT and DELETE /orgs/{org}/issue-types/{issue_type_id} (there is no
collection-level PATCH) โ but it needs admin:org (Issue Types write),
which the routine's PAT does not carry (it 403s). So: use the API when the token has the
scope โ that is the auditable path and it works unattended โ and fall back to org settings in a
browser only when it does not. Do not treat type-list drift as un-fixable just because one token
lacks the scope.
The roadmap axis. A roadmap layout plots date or iteration fields only โ it does not render
hierarchy. The Year iteration field (2026/2027/2028) is the current coarse axis and is assigned on
evidence of activity, never by assumption. Finer Start date/Due date values encode the
maintainer's intent โ do not invent them; propose a sequence and let him drag, or ask.
Fields. 50-field cap per project. Prefer an org-level issue field over a project field when the
value should be identical everywhere (25/org) โ project fields are per-board by design.
Insights. Charts exist and are underused. Note the trap: archived items are excluded from Insights
entirely, so an aggressive archive rule silently truncates historical burn-up.
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.
1---2name: maintain-project-board3description: 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.4---56# Maintain: ๐ Project Board (org project 5)78**[github.com/orgs/devantler-tech/projects/5](https://github.com/orgs/devantler-tech/projects/5)** is a9**product**, not a byproduct โ it is the maintainer's *single* surface for seeing what exists, what is10moving, and where it is headed across ~20 repos. It gets the same continuous-enhancement treatment as11every other product: it participates in the normal rotation, it has a roadmap, and drift in it is a12defect (maintainer direction 2026-07-18).1314Its users are the maintainer and the agent instances. **Judge it the way a user would: open it and ask15"can I tell what is happening here?"** โ not "is the data technically present". A board that is16accurate but unreadable has failed.1718Shared cross-repo rules live in the monorepo [`AGENTS.md`](../../../../AGENTS.md) โ in particular19*Issue hierarchy* and *Every issue belongs on the board*, which are binding on **every** run whatever20product it is working on. This card is the place where the board itself gets *improved*.2122## Health checks (the operate half โ cheap, every rotation)2324Run these as a survey pass; each has a known-good answer.2526๐ด **Do NOT check the board by enumerating it โ an explicit high limit does NOT make enumeration27safe.** `gh project item-list` defaults to `--limit 30`, but raising the limit only moves the cut:28measured 2026-08-06, `--limit 3000` returned **exactly 3000** items against a true29`projectV2{items{totalCount}}` of **4988**, with **no truncation signal** โ no warning, no error,30exit 0. ~40% of the board was silently absent, so every check below would have reported clean while31the drift sat past the cut. Returning exactly the limit is the *only* tell, and it is one the caller32has to look for.3334โ ๏ธ **Enumeration also costs the whole hourly GraphQL budget, which every lane shares.** That same35pass left `graphql: {limit:5000, remaining:73}`. The budget is attached to the **user**, so36all registered writer namespaces draw on it โ and the surveyor's paginated `reviewThreads`37queries are what the hygiene pentad's unresolved-thread count depends on. A starved pentad reads as38*clean*, which is a **fail-open on the promotion gate**. Never spend the shared budget on a board39sweep to answer a question a per-issue query answers for ~1 point.4041**So: ask per issue, not per board.** For coverage and membership, query the issue's own side. That42read is cheap and it distinguishes "on no project" from "on the board" (negative control:43`platform#1` โ `[]`, verified non-vacuous) โ but it carries the *same* three fail-open traps the44enumeration does, so it gets the same discipline:4546- **Identify the board by its `id`, never by `number` alone.** A number is unique only within one47 owner, so a repository-level project or another owner's project numbered 5 satisfies a48 number-only match and reports an unboarded issue as covered.49- **Paginate.** `projectItems(first: N)` truncates exactly like `item-list --limit N`, and here the50 truncated read produces the *worse* answer: "not on the board" for an issue that is.51- **Fail closed on the read itself.** An empty `projectItems` and a failed query look identical52 downstream. Only a successful query may be read as "not on a project"; anything else is53 **unverified**, which is a different outcome from either answer.5455`includeArchived: false` is explicit because an archived item is not on the board for triage โ it is56covered by the archive, not by the sweep, and the default would quietly count it as coverage.5758```sh59# Resolved once per run; a number alone is not an identity.60BOARD_ID=$(gh api graphql -f query='{organization(login:"devantler-tech"){projectV2(number:5){id}}}' \61 --jq '.data.organization.projectV2.id') \62 || { echo "board id lookup failed; refusing" >&2; exit 1; }6364# 0 = on the board, 1 = verified absent, 2 = UNVERIFIED (never conflate 1 and 2).65on_board() { # on_board <owner> <repo> <issue-number>66 _ob_after=null # prefixed: the function assigns in the caller's scope67 while :; do68 _ob_page=$(gh api graphql -F owner="$1" -F name="$2" -F number="$3" -F after="$_ob_after" -f query='69 query($owner:String!,$name:String!,$number:Int!,$after:String){70 repository(owner:$owner,name:$name){ issue(number:$number){71 projectItems(first:100, after:$after, includeArchived:false){72 nodes{ project{ id } } pageInfo{ hasNextPage endCursor } } } } }') || return 273 printf '%s' "$_ob_page" | jq -e '.data.repository.issue.projectItems' >/dev/null || return 274 if printf '%s' "$_ob_page" | jq -e --arg id "$BOARD_ID" \75 '[.data.repository.issue.projectItems.nodes[].project.id] | index($id)' >/dev/null; then76 return 077 fi78 printf '%s' "$_ob_page" | jq -e '.data.repository.issue.projectItems.pageInfo.hasNextPage' >/dev/null \79 || return 180 _ob_after=$(printf '%s' "$_ob_page" | jq -r '.data.repository.issue.projectItems.pageInfo.endCursor')81 done82}83```8485(`-F` is what makes the first page's `null` a JSON null rather than the string `"null"`; a later86cursor that happened to look like a number or boolean would be coerced too, and GraphQL would reject87it โ an error, so `2`, never a wrong answer. `index($id)` returning **0** for the first item is88truthy to `jq -e`, which fails only on `false` and `null`; do not "fix" that into a length test.)8990**If a check genuinely needs the whole board**, pair the enumeration with a mandatory truncation91guard and treat a trip as a hard failure, never a result:9293```sh94total=$(gh api graphql -f query='{organization(login:"devantler-tech"){projectV2(number:5){items{totalCount}}}}' \95 --jq '.data.organization.projectV2.items.totalCount') \96 || { echo "totalCount query failed; refusing" >&2; exit 1; }97items=$(gh project item-list 5 --owner devantler-tech --format json --limit "$LIMIT") \98 || { echo "item-list failed; refusing" >&2; exit 1; }99n=$(printf '%s' "$items" | jq '.items | length')100for v in "$n" "$total"; do101 case "$v" in ''|*[!0-9]*) echo "non-numeric count [$v]; refusing" >&2; exit 1 ;; esac102done103if [ "$n" -eq "$LIMIT" ]; then echo "TRUNCATED at limit ($n); refusing" >&2; exit 1; fi104if [ "$n" -lt "$total" ]; then echo "INCOMPLETE: $n of $total; refusing" >&2; exit 1; fi105```106107(Written as `if`, not `cond && { โฆ; exit 1; }`. Measured: the `&&` form is safe mid-script โ `set -e`108exempts a non-final component of an AND-OR list โ but on the **healthy** path it evaluates to **1**,109so as the last command of a script or function it returns a spurious failure and takes a `set -e`110caller down with it. `if` has no such edge. Set `LIMIT` above `totalCount`; the equality arm is what111catches the silent cut.)112113๐ด **Each `gh` call is checked on its own line, and both counts are asserted numeric, because114otherwise this guard fails OPEN โ the exact direction it exists to prevent.** Measured: with `gh`115returning non-zero, the old one-line pipeline left `n` empty; `[ "" -eq "$LIMIT" ]` and116`[ "" -lt "$total" ]` then both exit **2** with `integer expression expected`, and a `[` failure117*inside an `if` condition* is exempt from `set -e` โ so both arms evaluated false, the script reached118its success path, and it **exited 0 having counted nothing**. A rate limit therefore read as a clean119board. `set -o pipefail` alone would not have saved it either: the pipeline masks `gh`'s status behind120`jq`'s. Verified in all three states โ `gh` failing โ exit 1, a genuine truncation (`n == LIMIT`) โ121exit 1, and a healthy full read โ exit 0, so the guard is not vacuous.122123Never suppress stderr on these calls: a rate-limited `gh` prints `API rate limit exceeded` and exits124non-zero, and a `2>/dev/null` turns that into an empty result indistinguishable from a clean board.125126| Check | Query | Healthy |127|---|---|---|128| **Coverage** | open issues in active **public** repos vs. items on the board (private-repo items are a maintainer decision, never counted against coverage) | 100% |129| **Status hygiene** | board items with no `Status` | 0 |130| **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) |131| **Hierarchy โ migration** | open issues with a prose `Part of #N` but no real parent link | 0 |132| **Hierarchy โ orphans** | open **non-Epic** issues with `no:parent-issue`, minus the contract's exemptions (hotfixes, trivial `Chore`s, standalone `Spike`s) | 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 |133| **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) |134| **Dangling parents** | `Part of` references resolving to a PR, to self, or to nothing | 0 |135| **Stale epics** | epics at `Sub-issues progress` 100% but still open | 0 (close or extend) |136| **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](https://github.com/devantler-tech/monorepo/issues/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 |137| **Reopened stuck at Done** | **open** issues whose Status is still `โ
Done` | 0 โ closedโDone is a built-in workflow but **nothing moves an issue back out on reopen**, and the Kanban filters `-status:"โ
Done"`, so reopened work vanishes from the main view exactly when it needs attention. Move it back to the state that matches reality (0 on 2026-07-18 โ latent, not live) |138| **Archive** | closed/merged items still active on the board | none older than **30 days closed** โ **except any item whose hierarchy still contains open work**, in either direction: a closed item with any still-open **ancestor** (a closed sub-Epic under an open top-level Epic keeps its own closed children too), *and* a closed item with any still-open **descendant** (an Epic closed prematurely while a child is still open must stay visible, or the open child is orphaned from a tree nobody can see). These stay active. Archiving it removes it from the Backlog's hierarchy, so the decomposition becomes unreadable while the progress bar still counts it, and "what is part of what" (the whole point of the board) silently loses rows. Younger items also stay active so Insights keeps recent history โ see the Insights trap below; manual until [#2238](https://github.com/devantler-tech/monorepo/issues/2238)'s auto-archive works |139140**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.141142Coverage and hierarchy are the two that silently rot, because **auto-add is forward-only and capped at1435 workflows on the Team plan** โ see [monorepo#2237](https://github.com/devantler-tech/monorepo/issues/2237).144Until that is solved, backfill is a standing duty, not an exception.145146## What "advance" means here147148- **Views โ exactly three, and resist adding a fourth.** The board carries a **kanban (board)**, a149 **backlog (table)** and a **roadmap (roadmap)** view. **Epic breakdown lives on the Backlog view via150 the `Show hierarchy` toggle, NOT as a separate view and NOT as a `Parent issue` group-by** โ151 maintainer direction 2026-07-18, when a proposed fourth "By epic" view was folded into the Backlog152 instead. The principle generalises: **prefer an extra grouping, slice or filter on an existing view153 over a new view.**154 Every additional view is another surface to keep honest and another place for the maintainer to155 look; a board with three well-configured views beats one with seven overlapping ones. Add a fourth156 only when a genuine audience or cadence cannot be served by grouping an existing view.157 โ ๏ธ **Creating a view is scriptable; EDITING one is not.** REST documents158 **`POST /orgs/{org}/projectsV2/{project_number}/views`** with `name`, `layout`159 (`table`/`board`/`roadmap`), `filter` and `visible_fields` โ so a *new* view can be created from the160 API. There is **no documented PATCH/DELETE for a view**, `ProjectV2View` is a **read-only** GraphQL161 type (no view mutations), and `gh project` has no view subcommand โ so **changing an existing view's162 filter, layout, grouping or toggles needs a browser** with the maintainer's session. (A GET against163 the views path 404s on this org, so treat the POST as documented-but-unexercised: verify before164 relying on it, and don't create a throwaway view to test โ there is no documented way to delete it.)165 Propose view *edits* precisely (layout, filter string, grouping, visible fields) so applying them by166 hand is mechanical.167168 **The only browser an agent can DRIVE is Chrome via the Claude extension.** Computer-use grants169 browsers at **read tier only** โ screenshots work, clicks and typing are blocked at the OS level โ170 so Safari/Firefox/Arc can be *seen* but never operated, by design. Don't burn a round trip171 requesting browser access for a click-through task; check `list_connected_browsers` first, and if172 nothing is connected, say so and offer the tooltip-guided walkthrough instead. When saving a view,173 GitHub prompts *"make it the default for everyone"* โ the board is shared, so **every view save is174 a public change**.175176 **Current configuration** (verified live 2026-07-18):177178 | View | Layout | Filter |179 |---|---|---|180 | ๐งฎ Kanban | Board | `is:issue is:open -status:"โ
Done" -type:"Epic"` |181 | ๐ Backlog | Table | `is:issue is:open no:parent-issue` |182 | ๐บ๏ธ Roadmap | Roadmap | `is:issue is:open type:"Epic" no:parent-issue` |183184 **Keep these three filters.** [#2266](https://github.com/devantler-tech/monorepo/issues/2266)185 asked whether open work under a *closed* root should be fixed by changing the Backlog filter,186 re-opening the root, or re-parenting. **Decision (2026-07-22): re-open the root** (and never close187 an Epic with open descendants). A filter change cannot express that case, a fourth view would188 violate the three-view rule, and re-parenting destroys the recorded decomposition. No maintainer189 UI edit is required for this decision.190191 **Each view shows one layer of the hierarchy, and only one** (maintainer direction 2026-07-18):192 **Kanban = actionable work only** โ a parent is resolved by finishing its children, so an epic is193 never something you drag across a board; **Backlog = top-level only** (`no:parent-issue`) with194 children reached by *expanding* the hierarchy, never listed as sibling rows; **Roadmap = top-level195 Epics**.196197 โ ๏ธ **Filter on `type:"Epic"`, NOT on `no:sub-issues-progress` or `label:roadmap`.** The structural198 filter only excludes epics that have *already* been decomposed โ 37 **undecomposed** epics were199 sitting on the Kanban looking like actionable work, because an epic nobody has broken down yet has no200 children to detect. Switching to `-type:"Epic"` dropped the Kanban 166 โ 130 and is correct from the201 moment an issue is filed. The label is likewise unreliable: it depends on label hygiene, and `Epic`202 as a type is a superset (62 epics vs 55 roadmap-labelled).203204 **The board tracks ISSUES, not PRs** (maintainer direction 2026-07-18): a PR that closes an issue205 stands in for that issue's progress, so showing both double-counts the same work โ dropping `,pr`206 took the Ready column from 8/5 to a true 2/5. โ ๏ธ **No automation moves an issue card from its207 linked PR's state** โ native Project workflows act only on the project's own items, and nothing in208 this org updates an issue's Status from PR events. **The agent working the PR moves the issue's209 Status by hand at each lifecycle step, **when that step's entry condition in `AGENTS.md` actually210 holds** โ a PR merely being *open* is still ๐๐ปโโ๏ธ In Progress; it becomes ๐ In Review only once CI is211 green **and** a review has been requested, and ๐ Ready to Merge only on a green review at head with212 the pentad clear. Moving a card early makes the board overstate progress, which is the one thing a213 status ladder must never do. Only the final closedโDone move is a built-in workflow.214215 โ ๏ธ **๐ Verifying belongs on the issue that is still OPEN, not on the one the merge just closed.**216 A delivery PR carries `Fixes #delivery`, so merging **closes** that issue and the built-in217 closedโDone workflow marks it โ
Done โ moving it to *Verifying* would either be immediately218 overwritten or leave finished work looking pending. When the outcome cannot be known at merge, the219 PR also carries `Part of #experiment`; **that experiment/Kata issue stays open, and it is the one220 that goes to ๐ Verifying** until its measurement date, when it becomes โ
Done with the decision221 recorded. If a change has no separate experiment issue but still needs a post-merge check, keep the222 delivery issue **open** (drop `Fixes`, use `Part of`) rather than closing it and reopening it. **This also makes223 `Fixes #N` load-bearing for visibility, not just bookkeeping:** an agent-authored PR with no linked224 issue is now *invisible on the board*, so the capture-before-you-build rule is what keeps the board225 complete. When a substantive PR has no issue, the fix is to file/link the issue โ never to put PRs226 back on the board. Dependency-automation PRs correctly disappear: they are automation-owned and227 need no action, so they were only ever noise on a planning surface.228229 Backlog has **Show hierarchy = On**, which nests sub-issues under their parents inline (up to 8230 levels) โ that is what makes it the by-epic view; do not add a `Parent issue` group-by on top, and do231 not turn the toggle off. Roadmap binds Start/Target to the **`Year` iteration's `Year start`/`Year232 end`**, zoom Year, with the Year marker on; items without a Year show an unscheduled `+` placeholder,233 which is the honest rendering of "not scheduled".234235 **Hierarchy filters โ use the FIELD name, and get it from the UI, not from docs.** The working236 qualifiers are **`has:sub-issues-progress`** (is a parent) / **`no:sub-issues-progress`** (is a leaf),237 and `has:parent-issue` / `no:parent-issue` / `parent-issue:owner/repo#N`. They key off the *project238 field* name ("Sub-issues progress"), **not** the issue-search spelling: **`has:sub-issue` is NOT a239 valid project filter** โ GitHub silently ignores an unrecognised qualifier rather than erroring, so a240 wrong guess looks like it worked.241242 โ ๏ธ **Two method rules, learned the hard way on 2026-07-18:**243 1. **Never compare item counts across views to test a filter.** Board and table layouts count244 differently โ the same stricter filter read 193 on the board and 163 in the table. A cross-view245 comparison produced a confidently wrong conclusion (that `has:sub-issue` was being ignored *and*246 that no parent filter existed). **Always A/B a filter within ONE view.**247 2. **Type `no:` into the filter box to get the authoritative qualifier list.** GitHub autocompletes248 every supported field; picking from that list inserts the exact syntax. That is faster and more249 reliable than docs, which do not enumerate project-field qualifiers.250- **Status semantics.** Options run **โ
Done โ ๐ Verifying โ ๐ Ready to Merge โ ๐ In Review โ251 ๐๐ปโโ๏ธ In Progress โ ๐ซด Ready โ ๐ฅ Backlog โ ๐ง Icebox**, and the **merge is the boundary** between252 *Ready to Merge* (pre-merge, mechanical) and *Verifying* (post-merge, evidential). The order is253 **deliberately reversed** โ finishing work sits leftmost, so the board reads *stop starting, start254 finishing* (maintainer direction 2026-07-18). **Never re-order it into left-to-right flow.** Column255 **limits** encode the same WIP discipline; treat an over-limit column as a signal to finish, never as256 a reason to raise the limit. **"Blocked" is intentionally not a status** โ use a native issue257 dependency (Blocked badge renders in-place) and reserve the `blocked` label for cross-org blockers.258- **Issue Types are mandatory** โ every issue carries exactly one of **Epic, Feature, Bug, Security,259 Performance, Refactor, Docs, Spike, Kata, Chore** (see the contract's *Issue hierarchy* for the260 definition-of-done each implies). An untyped issue is an incomplete issue. Setting one always works,261 but **always name the repo** โ a bare number resolves in the *current* repo, so triaging another262 repo's item from the monorepo would type the same-numbered monorepo issue instead:263 `gh issue edit <N> --repo devantler-tech/<repo> --type "<Type>"` (or pass the issue URL). Managing264 the org type **list** is a REST surface โ collection `GET`/`POST /orgs/{org}/issue-types`, then265 item-specific **`PUT`** and **`DELETE` `/orgs/{org}/issue-types/{issue_type_id}`** (there is no266 collection-level PATCH) โ but it needs **`admin:org` (Issue Types write)**,267 which the routine's PAT does **not** carry (it 403s). So: **use the API when the token has the268 scope** โ that is the auditable path and it works unattended โ and fall back to **org settings in a269 browser** only when it does not. Do not treat type-list drift as un-fixable just because one token270 lacks the scope.271- **The roadmap axis.** A roadmap layout plots **date or iteration fields only** โ it does **not** render272 hierarchy. The `Year` iteration field (2026/2027/2028) is the current coarse axis and is assigned **on273 evidence of activity**, never by assumption. Finer `Start date`/`Due date` values encode *the274 maintainer's intent* โ **do not invent them**; propose a sequence and let him drag, or ask.275- **Fields.** 50-field cap per project. Prefer an **org-level issue field** over a project field when the276 value should be identical everywhere (25/org) โ project fields are per-board by design.277- **Insights.** Charts exist and are underused. Note the trap: **archived items are excluded from Insights278 entirely**, so an aggressive archive rule silently truncates historical burn-up.279280## Mutation safety281282- `updateProjectV2Field` with `singleSelectOptions` **replaces the whole option list** โ always pass the283 existing option **`id`**s or every assignment is destroyed. Verify emoji codepoints after writing284 (๐ซด Ready is **U+1FAF4**; a wrong codepoint silently rewrites the option name).285- Adding an item and setting its fields are **two separate calls** โ you cannot do both at once.286- **Pace bulk work**: ~80 content-generating requests/minute, 500/hour. Serialize; never fan out.287- The board is **public**. Adding items from **private** repos is a maintainer decision, not an288 agent default.289290## Roadmap & enhancement291292Roadmap lives in **GitHub Issues on `devantler-tech/monorepo`** โ never enumerated here, because a293hard-coded list goes stale the moment an issue closes or a new one is filed (the same mistake the294retired status-board made). Query it live:295296That issue repository also anchors coordination for path-less board/API work: acquire297`agent-claim/<issue>` against the monorepo root before mutating, retain the returned SHA, **atomically298renew the retained SHA immediately before the board mutation** with299`claim_sha="$(.claude/scripts/agent-claim.sh renew <issue> "$claim_sha" --repo-dir <monorepo-root>)"`,300and retire that renewed SHA only after the resulting board state is read back and verified. A failed301renew means a takeover won or ownership is unknown, so stand down without mutating. A comment is an302activity record, not an atomic claim.303304Board work hangs off the board Epic โ **[monorepo#2261](https://github.com/devantler-tech/monorepo/issues/2261)** โ305so it is found **structurally**, never by string matching:306307```sh308gh api "search/issues?q=org:devantler-tech+is:issue+is:open+parent-issue:devantler-tech/monorepo%232261&per_page=100" \309 --jq '.items[] | "#\(.number)\t\(.created_at[0:10])\t\(.title)"'310```311312A text search (`"board" in:title,body`) was tried and rejected: it misses board issues that don't313contain the word ("Automate stale-item archiving") and pulls in unrelated issues that merely mention a314board. A hard-coded list was rejected before that, for going stale on the first close. **File new board315work as a child of #2261** โ the contract's default that every issue belongs to an Epic, applied to the316board's own product.317318The strategic frame: *what can the maintainer still not see at a glance?* Answer that, and the board has319advanced.