Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/changed-readable-human-gate-advice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The weekly owner advice "Clear the human gate on PRs" and "Unblock the human queue" are readable. They no longer say only "59 of 94 open PRs are your turn (1 INSPECT, 24 DECIDE, 34 REVIEW)" followed by five items whose notes could run to a paragraph. Each now gives the count waiting on a person, **why they wait** (grouped by reason in plain words, with where they sit, such as "24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite)"), a **How to help** line for the largest groups, and one short line per listed item. The raw signals moved to the end of the digest entry and fold away on the dashboard card. The API adds `why` and `help` to those recommendations.
26 changes: 24 additions & 2 deletions src/docs/advisory.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,15 +427,34 @@ Two families of rules feed the block:
| `reduce-blocked-prs` | Blocked PRs ≥ `blocked_pr_pct` of open PRs | the dominant cause and the first action: *"41 of 70 fail check `go-security-analysis` — fix the check before touching cadence"*, *"N have merge conflicts — rebase or close the oldest"*, or the top blocked-verdict reason |
| `fix-failing-check` | one check fails on ≥ `check_share_pct` of CI-blocked PRs (at least 2) | the check name and how many PRs it holds |
| `throttle-lane` | one agent lane produced ≥ `lane_share_pct` of Blocked PRs (at least 2) | the lane to slow down |
| `clear-human-gate-prs` | PRs that are your turn (the inbox's N plus M) ≥ `needs_human_pr_pct` of open PRs | the per-verb split of those PRs |
| `unblock-human-issues` | Issues that are your turn (the inbox's N plus M, parked issues included) ≥ `needs_human_issue_pct` of open issues | the per-verb split of those issues |
| `clear-human-gate-prs` | PRs that are your turn (the inbox's N plus M) ≥ `needs_human_pr_pct` of open PRs | how many wait on a person, **why** they wait (grouped by reason, with where they sit) and **how to help** |
| `unblock-human-issues` | Issues that are your turn (the inbox's N plus M, parked issues included) ≥ `needs_human_issue_pct` of open issues | the same why / how to help breakdown for issues |
| `review-stale-prs` | Blocked PRs past `dashboard.issue_bands.stale_days` ≥ `stale_blocked_prs` | stale = no activity past the threshold, not proven dead |
| `confirm-and-close` | any issue in Confirm & close | how much of the backlog confirming them removes |

The two needs-human rules also carry:

- **Why they wait:** the your-turn items grouped by the reason the dashboard
recorded for each (its ownership reason code), in plain words, largest
group first, each with where it sits, for example *"24 parked until a
maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite)"*. A
reviewer's requested changes and a code owner's review are their own
groups, apart from approvals branch protection requires. The five largest
groups are named; the rest fold into one "N more for other reasons" line.
- **How to help:** the action for the three largest groups, naming the
repositories only when two of them hold most of that group, for example
*"answer the parked decisions in utah-packages and utah; approve PRs in
common and actions"*.

Every queue-health recommendation carries:

- **First:** the first five issues/PRs it is about (oldest activity first),
each with why it is listed (the failing check, "merge conflict", "held").
A needs-human item's note is its verb and reason, cut to the reason's first
clause when it runs past 100 characters (a parked hive review lists every
reviewer's summary); a review-decision note drops "Waiting on a person's
review:", which the REVIEW verb already says. The inbox and the CSV keep the
whole reason.
- **Full list:** the matching Overview export, e.g.
`/api/overview/prs.csv?band=blocked` (plus a `repo=` link when one repo
holds half the blocked PRs). The digest prefixes the link with
Expand All @@ -444,6 +463,9 @@ Every queue-health recommendation carries:
- **Band rules:** the one-line rule for each band the advice names, the same
text the Overview tooltips print — so *Blocked* is read as "blocked sweep
verdict, merge conflicts, or failing CI", not the `blocked` label.
- **Numbers behind this:** the raw signals the rule read (including the
per-verb split), last in the digest and folded away on the dashboard
card.

### Which count is quoted

Expand Down
2 changes: 1 addition & 1 deletion src/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of `
| `GET` | `/api/acmm/evaluation` | Dashboard auth/session | ACMMEvaluation — combined codebase + operational result, cached server-side for 1 hour (`acmmEvalTTL`). `?refresh=1` (the dashboard's "🔄 Re-evaluate" button, #5877) bypasses the hourly TTL but is debounced server-side: requests within 1 minute of the last evaluation (`acmmRefreshDebounce`) still serve the cache, since a full refresh costs up to ~29 GitHub GetContents calls per repo. The response's `last_evaluated_at` timestamp reports when the cached evaluation was computed | `pkg/dashboard/api.go:352`, `pkg/dashboard/api_acmm_eval.go` |
| `POST` | `/api/acmm/issue` | Owner only | ACMMCreate Issue — files on GitHub or, with `governor.acmm.issue_tracker: work_source` / body `tracker: "work_source"` on a Linear-sourced hive, on Linear; response `tracker` says which. See [ACMM policy matrix](acmm-policy-matrix.md#where-acmm-gap-issues-are-filed) | `pkg/dashboard/api.go:353` |
| `GET` | `/api/acmm-recommendation` | Dashboard auth/session | Advisory level-up recommendation (`acmmadvisor.Recommendation`, JSON): never changes the applied level — see [ACMM advisor](acmm-advisor.md) | `pkg/dashboard/api.go:354` |
| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call — see [Owner advice](advisory.md#owner-advice) | `pkg/dashboard/api.go:356`, `pkg/dashboard/api_hive_advice.go` |
| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`, and on the needs-human advice `why` (plain-language reasons with counts) and `help`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call — see [Owner advice](advisory.md#owner-advice) | `pkg/dashboard/api.go:356`, `pkg/dashboard/api_hive_advice.go` |

## Cost, tokens, telemetry

Expand Down
36 changes: 35 additions & 1 deletion src/pkg/advisory/advice_section_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ func TestAdviceSectionRendersQueueExplanation(t *testing.T) {
withOrigin := FormatDigestMarkdown(BuildDigest(nil, "IDLE"), DigestOptions{ShowEmpty: true, Advice: advice, DashboardURL: "https://hive.example.com/"})
for _, want := range []string{
"- **Reduce the blocked PR queue** — 70 of 93 open PRs (75%) are Blocked.",
" - First: [octo/demo#12](https://github.com/octo/demo/pull/12) (fails go-security-analysis), octo/demo#13 (merge conflict)\n",
" - First:\n - [octo/demo#12](https://github.com/octo/demo/pull/12) — fails go-security-analysis\n - octo/demo#13 — merge conflict\n",
" - Numbers behind this: blocked=70\n",
" - Full list: [Blocked PRs (CSV)](https://hive.example.com/api/overview/prs.csv?band=blocked)\n",
"- ~~**Widen HIVE_REPOS**~~ — No longer applies",
"Bands named above (same rules as the Overview chart):\n- **Blocked** (PRs): blocked sweep verdict, merge conflicts, or failing CI",
Expand All @@ -80,3 +81,36 @@ func TestAdviceSectionRendersQueueExplanation(t *testing.T) {
t.Fatalf("relative export path should render as code without a dashboard origin:\n%s", withoutOrigin)
}
}

// The needs-human advice reads as a short list: the headline, why the items
// wait (one line per reason), how to help, then one item per line, with the
// raw signals last instead of trailing the headline.
func TestAdviceSectionRendersWhyAndHowToHelp(t *testing.T) {
advice := &hiveadvisor.Result{
Epoch: hiveadvisor.Epoch{Mode: "BUSY", End: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)},
Recommendations: []hiveadvisor.Recommendation{{
ID: "clear-human-gate-prs",
Title: "Clear the human gate on PRs",
Rationale: "59 of 94 open PRs (62%) are waiting on a person; the hive cannot move them on its own.",
Why: []string{
"24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite).",
"21 waiting for an approving review that branch protection requires (16 in common, 5 in actions).",
},
Help: "answer the parked decisions in utah-packages and utah; approve PRs in common and actions",
Items: []hiveadvisor.Item{{Repo: "actions", Number: 675, Note: "REVIEW — Hive's review is parked for a person"}},
Signals: []hiveadvisor.Signal{{Name: "needs_human", Value: "59"}},
}},
}
md := FormatDigestMarkdown(BuildDigest(nil, "BUSY"), DigestOptions{ShowEmpty: true, Advice: advice})
want := "- **Clear the human gate on PRs** — 59 of 94 open PRs (62%) are waiting on a person; the hive cannot move them on its own.\n" +
" - Why they wait:\n" +
" - 24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite).\n" +
" - 21 waiting for an approving review that branch protection requires (16 in common, 5 in actions).\n" +
" - **How to help:** answer the parked decisions in utah-packages and utah; approve PRs in common and actions.\n" +
" - First:\n" +
" - actions#675 — REVIEW — Hive's review is parked for a person\n" +
" - Numbers behind this: needs_human=59\n"
if !strings.Contains(md, want) {
t.Fatalf("digest advice block:\n%s\nwant:\n%s", md, want)
}
}
31 changes: 20 additions & 11 deletions src/pkg/advisory/advisory.go
Original file line number Diff line number Diff line change
Expand Up @@ -1458,28 +1458,30 @@ func writeAdviceSection(b *strings.Builder, advice *hiveadvisor.Result, dashboar
b.WriteString(fmt.Sprintf("- ~~**%s**~~ — %s\n", rec.Title, rec.Rationale))
continue
}
b.WriteString(fmt.Sprintf("- **%s** — %s", rec.Title, rec.Rationale))
if len(rec.Signals) > 0 {
parts := make([]string, 0, len(rec.Signals))
for _, s := range rec.Signals {
parts = append(parts, fmt.Sprintf("%s=%s", s.Name, s.Value))
// One idea per line: the headline, why the items wait, how to help,
// where to start, the full list, and last the raw signals.
b.WriteString(fmt.Sprintf("- **%s** — %s\n", rec.Title, rec.Rationale))
if len(rec.Why) > 0 {
b.WriteString(" - Why they wait:\n")
for _, w := range rec.Why {
b.WriteString(" - " + w + "\n")
}
b.WriteString(" (signals: " + strings.Join(parts, ", ") + ")")
}
b.WriteString("\n")
if rec.Help != "" {
b.WriteString(" - **How to help:** " + rec.Help + ".\n")
}
if len(rec.Items) > 0 {
parts := make([]string, 0, len(rec.Items))
b.WriteString(" - First:\n")
for _, it := range rec.Items {
ref := fmt.Sprintf("%s#%d", it.Repo, it.Number)
if it.URL != "" {
ref = fmt.Sprintf("[%s](%s)", ref, it.URL)
}
if it.Note != "" {
ref += " (" + it.Note + ")"
ref += " — " + it.Note
}
parts = append(parts, ref)
b.WriteString(" - " + ref + "\n")
}
b.WriteString(" - First: " + strings.Join(parts, ", ") + "\n")
}
if len(rec.Links) > 0 {
parts := make([]string, 0, len(rec.Links))
Expand All @@ -1492,6 +1494,13 @@ func writeAdviceSection(b *strings.Builder, advice *hiveadvisor.Result, dashboar
}
b.WriteString(" - Full list: " + strings.Join(parts, ", ") + "\n")
}
if len(rec.Signals) > 0 {
parts := make([]string, 0, len(rec.Signals))
for _, s := range rec.Signals {
parts = append(parts, fmt.Sprintf("%s=%s", s.Name, s.Value))
}
b.WriteString(" - Numbers behind this: " + strings.Join(parts, ", ") + "\n")
}
}
if len(advice.Bands) > 0 {
b.WriteString("\nBands named above (same rules as the Overview chart):\n")
Expand Down
79 changes: 79 additions & 0 deletions src/pkg/dashboard/hive_advice_readable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package dashboard

import (
"os/exec"
"strings"
"testing"

"github.com/hivecommons/hive/pkg/hiveadvisor"
)

// The needs-human advice words each item's reason code in plain language
// (hiveadvisor.gateReasons). A code the dashboard can produce but the advisor
// has no words for still groups, under its raw reason text, which is the
// wording the advice moved away from: keep the two lists in step.
func TestHiveAdviceWordsEveryOwnershipReason(t *testing.T) {
for code := range ownershipReasonVerbs {
if !hiveadvisor.DescribesReasonCode(code) {
t.Errorf("ownership reason %q has no plain-language wording in hiveadvisor.gateReasons", code)
}
}
}

// Execute the shipped card renderer: the advice reads as headline, why the
// items wait, how to help, one item per line, then the full list, with the
// raw signals folded away.
func TestHiveAdviceCardReadsAsWhyHelpAndItems(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node unavailable: hive advice card rendering was not executed")
}
html := indexHTML(t)
var source strings.Builder
source.WriteString(`const assert = require('node:assert/strict');
const nodes = { 'hive-advice-section': { style: {} }, 'hive-advice-card': { innerHTML: '' } };
const document = { getElementById: (id) => nodes[id] || null };
let _lastHiveAdvice = null;
function refreshSectionCardShell() {}
function setSectionSummary() {}
function applySectionCollapse() {}
function formatDashboardDate() { return '11/2'; }
`)
for _, name := range []string{"escapeHtml", "renderHiveAdvice"} {
source.WriteString(jsFunc(t, html, name))
source.WriteByte('\n')
}
source.WriteString(`renderHiveAdvice({
epoch: { mode: 'BUSY', end: '2026-11-02T00:00:00Z' },
nextReviewInDays: 6,
recommendations: [{
id: 'clear-human-gate-prs', title: 'Clear the human gate on PRs',
rationale: '59 of 94 open PRs (62%) are waiting on a person; the hive cannot move them on its own.',
why: ['24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite).', '21 waiting for an approving review that branch protection requires (16 in common, 5 in actions).'],
help: 'answer the parked decisions in utah-packages and utah; approve PRs in common and actions',
items: [{ repo: 'actions', number: 675, url: 'https://github.com/projectbluefin/actions/pull/675', note: "REVIEW — Hive's review is parked for a person" }, { repo: 'actions', number: 544, note: 'REVIEW — Waiting on a person\'s review: a reviewer requested changes' }],
links: [{ label: 'Your-turn PRs (CSV)', url: '/api/overview/prs.csv?slice=your-turn' }],
signals: [{ name: 'needs_human', value: '59' }, { name: 'verbs', value: '1 INSPECT, 24 DECIDE, 34 REVIEW' }],
}, { id: 'widen-hive-repos', title: 'Widen HIVE_REPOS', rationale: 'No longer applies.', cleared: true }],
});
const out = nodes['hive-advice-card'].innerHTML;
const order = ['59 of 94 open PRs', 'Why they wait', '24 parked until a maintainer decides', 'How to help:', 'First', 'actions#675', 'Full list:', 'Numbers behind this'];
let at = -1;
for (const want of order) {
const i = out.indexOf(want, at + 1);
assert.ok(i > at, 'expected ' + JSON.stringify(want) + ' after position ' + at + ' in:\n' + out);
at = i;
}
assert.match(out, /<ul class="hive-advice-why"><li>24 parked until a maintainer decides[^<]*<\/li><li>21 waiting for an approving review/);
assert.match(out, /<strong>How to help:<\/strong> answer the parked decisions in utah-packages and utah; approve PRs in common and actions\.<\/div>/);
assert.match(out, /<ul class="hive-advice-items"><li><a href="https:\/\/github\.com\/projectbluefin\/actions\/pull\/675"[^>]*>actions#675<\/a> — REVIEW — Hive&#39;s review is parked for a person<\/li><li>actions#544 — /);
assert.match(out, /<details class="hive-advice-signals"><summary>Numbers behind this<\/summary>needs_human=59, verbs=1 INSPECT, 24 DECIDE, 34 REVIEW<\/details>/);
assert.ok(!out.includes('First: '), 'items are a list, not a comma run');
assert.ok(!/class="crit-cmp">signals:/.test(out), 'raw signals are folded away');
assert.match(out, /<s>Widen HIVE_REPOS<\/s>/);
assert.equal(out.match(/hive-advice-why/g).length, 1, 'only the needs-human advice carries a why list');
`)
if out, err := exec.Command(node, "-e", source.String()).CombinedOutput(); err != nil {
t.Fatalf("hive advice card renderer failed: %v\n%s", err, out)
}
}
Loading
Loading