From 2dbd1fda3271935406f1190c4d39c9185a0892b1 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Wed, 28 Oct 2026 18:00:16 -0400 Subject: [PATCH 01/10] Model capability gaps as first-class blockers so delegated decisions stop re-parking loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An issue the scanner parks because the GitHub App cannot push .github/workflows/* (no workflows permission) is a capability gap, not a question: a direction decision cannot clear it, and "Let hive decide" un-parked hivecommons/hive#14976 only for the sweep to re-apply the same needs-human text an hour later. - pkg/blocker: structured blocker kinds, the marker, prose inference, and a persisted ledger with a delegated-unpark loop guard and a cleared-at staleness guard. - Record blockers where comments are read: issue_comment webhook, task-list sweep (adds the marker to the remainder comment), gh-wrapper (adds the marker to a needs-human comment that names a missing capability). - Stage engine: blocker parks 045 with "blocked: "; needs-human beside approved-direction keeps 045 and clears the ack (needs-human is the newer signal by construction). - Dashboard: decision_needed.blocker/options populated with real handlers (App settings link, Re-check permissions, close as partially addressed, wait; patch hand-off disabled with its reason), delegated decision short-circuits to no-effect with the glossary 'blocker' reason, the row pill reads "Blocked · App lacks workflows permission", ⚙ → Security and /api/status.capabilities name missing permissions and blocked counts. - Tests: Go (blocker, ledger, webhook, sweep marker, stage facts/derive, dashboard handlers, glossary) and node fixtures (pill, options, popover, capability text); gh-wrapper bash tests. - Docs: labels-and-control-signals (blocker marker + kinds + reconcile rule), dashboard-sections, operator-reference (App permissions table), api-reference; changelog fragment. Fixes #15099 Signed-off-by: Andrew Anderson --- bin/gh-wrapper.sh | 45 ++ bin/gh-wrapper.test.sh | 39 ++ .../fixed-capability-blocker-decision.md | 1 + src/docs/api-reference.md | 2 + src/docs/dashboard-sections.md | 2 + src/docs/labels-and-control-signals.md | 31 ++ src/docs/operator-reference.md | 30 ++ src/pkg/blocker/blocker.go | 328 ++++++++++++++ src/pkg/blocker/blocker_test.go | 206 +++++++++ src/pkg/blocker/ledger.go | 349 +++++++++++++++ src/pkg/dashboard/api.go | 2 + .../api_repo_item_decide_delegated.go | 17 + .../dashboard/delegated_decision_effect.go | 19 + src/pkg/dashboard/item_blocker.go | 406 ++++++++++++++++++ src/pkg/dashboard/item_blocker_test.go | 358 +++++++++++++++ src/pkg/dashboard/server.go | 10 +- src/pkg/dashboard/stage_tracker.go | 14 +- .../stage_tracker_blocker_js_test.go | 139 ++++++ src/pkg/dashboard/stage_tracker_decision.go | 14 + .../stage_tracker_row_actions_test.go | 2 + .../stage_tracker_sentinel_lane_test.go | 3 + src/pkg/dashboard/static/index.html | 87 +++- src/pkg/delegatedecision/decision_glossary.go | 11 + .../decision_glossary_test.go | 4 + src/pkg/github/app.go | 48 +++ src/pkg/github/issue_blocker_test.go | 104 +++++ src/pkg/github/issue_stage_facts.go | 16 + src/pkg/github/task_list_sweep.go | 11 + src/pkg/github/webhook_cache.go | 34 ++ src/pkg/stageengine/derive_issue.go | 30 +- src/pkg/stageengine/derive_issue_test.go | 45 ++ src/pkg/stageengine/facts.go | 15 + 32 files changed, 2415 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixed-capability-blocker-decision.md create mode 100644 src/pkg/blocker/blocker.go create mode 100644 src/pkg/blocker/blocker_test.go create mode 100644 src/pkg/blocker/ledger.go create mode 100644 src/pkg/dashboard/item_blocker.go create mode 100644 src/pkg/dashboard/item_blocker_test.go create mode 100644 src/pkg/dashboard/stage_tracker_blocker_js_test.go create mode 100644 src/pkg/github/issue_blocker_test.go diff --git a/bin/gh-wrapper.sh b/bin/gh-wrapper.sh index 4619021314..b1ab8d4f8c 100755 --- a/bin/gh-wrapper.sh +++ b/bin/gh-wrapper.sh @@ -1174,6 +1174,46 @@ _identity_footer() { echo -e "$parts" } +# Structured blocker marker for needs-human reports (hivecommons/hive#15099). +# +# A report that says the App / token / hive *cannot* do something (push to +# .github/workflows without the `workflows` permission, change branch +# protection, set a secret, sign a CLA) describes a capability gap no +# "Let hive decide" can clear. pkg/blocker reads `` +# and parks the item on that blocker instead of a bare needs-human, so the +# dashboard says what is missing and the delegated decision answers +# "no-effect" with the real options. Prose is classified here with the same +# narrow rules as pkg/blocker.Infer (mirrored, not shared: this file runs +# where no Go binary is guaranteed). Only bodies that cite needs-human are +# marked, a body that already carries a marker is left alone, and anything +# the rules do not match stays unmarked — a wrong kind is worse than none. +_blocker_kind() { + local body + body="$(printf '%s' "$1" | tr '\n' ' ' | tr '[:upper:]' '[:lower:]')" + [[ "$body" == *"hive:blocker"* ]] && return 1 + [[ "$body" =~ needs[-\ ]human|needs\ a\ (human|person|maintainer)|a\ maintainer\ must|a\ person\ must ]] || return 1 + local cannot="(cannot|can\'t|can not|unable to|not able to|no permission to|not permitted to|is not allowed to)" + if [[ "$body" =~ (no|lacks?|lacking|missing|without)\ (the\ )?\`?workflows?\`?(:\ ?write)?\ permission ]] \ + || [[ "$body" =~ $cannot\ (push|modify|change|edit|write|update|touch|commit)[^.]{0,80}(\.github/workflows|workflow\ files?|workflows?/) ]] \ + || [[ "$body" =~ workflows?\ permission[^.]{0,40}(missing|lacking|not\ granted|needed|required) ]]; then + echo "capability:workflows-permission"; return 0 + fi + if [[ "$body" =~ $cannot[^.]{0,60}(branch[\ -]protection|rulesets?|protected\ branch) ]]; then + echo "capability:branch-protection"; return 0 + fi + if [[ "$body" =~ $cannot\ (set|add|create|read|rotate|update|configure)[^.]{0,60}(secrets?|credentials?) ]]; then + echo "capability:secret"; return 0 + fi + if [[ "$body" =~ $cannot\ (push|write|commit)[^.]{0,60}(repository|repo|branch|fork) ]] \ + || [[ "$body" =~ (no|lacks?|missing|without)\ (the\ )?\`?(contents|write|push)\`?(:\ ?write)?\ (permission|access) ]]; then + echo "capability:repo-write"; return 0 + fi + if [[ "$body" =~ (sign|signing|signed)[^.]{0,30}(cla|contributor\ license\ agreement) ]]; then + echo "human-only:cla"; return 0 + fi + return 1 +} + # Guard Markdown thematic breaks against becoming setext heading underlines. # # A line of `---` placed DIRECTLY under a paragraph is not a rule in Markdown — @@ -1319,6 +1359,11 @@ _inject_identity() { # the gap is exactly one blank line regardless of how the file ended. body_val="$(printf '%s\n' "${body_val}" | _guard_markdown_rules)" body_val="${body_val%"${body_val##*[![:space:]]}"}" + local blocker_kind + if blocker_kind="$(_blocker_kind "$body_val")"; then + body_val="${body_val} +" + fi new_args+=("--body" "${body_val} ${footer}") diff --git a/bin/gh-wrapper.test.sh b/bin/gh-wrapper.test.sh index a5a866087e..e8d80b6a85 100755 --- a/bin/gh-wrapper.test.sh +++ b/bin/gh-wrapper.test.sh @@ -820,6 +820,45 @@ else _body_fail "in-body --- rules" "no recorded 'gh issue comment' invocation" fi +# A needs-human report naming a capability the App lacks gets the structured +# blocker marker pkg/blocker reads (hivecommons/hive#15099): the dashboard +# then parks the item on "App lacks workflows permission" instead of a bare +# needs-human that "Let hive decide" un-parks and the scanner re-parks. +BLOCKER_BODY_FILE="${WORK_DIR}/blocker-body.md" +printf '%s\n' 'Verified: the Dockerfile wrappers are gone.' '' 'The remaining retry wrappers live in `.github/workflows/v2-ci.yml`, which the App token cannot push (no `workflows` permission); needs a human.' >"$BLOCKER_BODY_FILE" +_capture_run /dev/null issue comment 42 --repo test/repo -F "$BLOCKER_BODY_FILE" +if inv="$(_capture_invocation issue comment)" && body="$(_capture_body "$inv")"; then + desc="issue comment -F : gets before the footer" + if [[ "$body" == *$'needs a human.\n\n\n---\n'* ]]; then + echo "PASS: $desc"; PASSED=$((PASSED + 1)) + else + _body_fail "$desc" "marker missing or misplaced" "body: ${body}" + fi +else + _body_fail "blocker marker" "no recorded 'gh issue comment' invocation" +fi + +# A body that already carries a marker, and a needs-human that names no +# capability, are left exactly as written: a wrong kind is worse than none. +_capture_run /dev/null issue comment 42 --repo test/repo --body 'The App cannot push workflow files; needs a human. ' +if inv="$(_capture_invocation issue comment)" && body="$(_capture_body "$inv")"; then + desc="issue comment --body : no second blocker marker" + if [[ "$(printf '%s' "$body" | grep -c 'hive:blocker')" == "1" ]]; then + echo "PASS: $desc"; PASSED=$((PASSED + 1)) + else + _body_fail "$desc" "expected exactly one marker" "body: ${body}" + fi +fi +_capture_run /dev/null issue comment 42 --repo test/repo --body 'needs-human: the flaky test touches workflows and a maintainer should look.' +if inv="$(_capture_invocation issue comment)" && body="$(_capture_body "$inv")"; then + desc="issue comment --body : no blocker marker" + if [[ "$body" != *'hive:blocker'* ]]; then + echo "PASS: $desc"; PASSED=$((PASSED + 1)) + else + _body_fail "$desc" "unexpected marker" "body: ${body}" + fi +fi + # No body at all is still the case that MUST add one. _capture_run /dev/null pr comment 42 --repo test/repo _expect_injected_body "pr comment with no body flag still gets --body