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
49 changes: 35 additions & 14 deletions .claude/skills/backlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ loud, not quiet.
| `reporter`, `upstream`, `discussion` | they owe us | quiet — suppressed from actions, still counted and listed |
| `maintainer` | we owe them nothing; you owe us a decision | **loud — ranked above every other action (rank 0), with the open question attached** |

The one carve-out: `autonomous_analyze` fires from evidence (`bug` +
`ready-for-analysis` + non-maintainer author, and no `analyzed` /
`needs-human-review` label) and overrides the stale wait values — `reporter`,
`upstream`, `discussion` — so one of those cannot hide an item that is
actually waiting on analysis. The `maintainer` hold is NOT overridden: that
row means you owe a decision, so the carve-out stands down and the escalation
stays loudest — see Autonomous spend.

`analysis` is the fifth value and is not in the signed table above because it
means the same thing the digest's `awaiting_source: label` marks — a triage
gap, not a completed wait: set the field to what the item is actually waiting
Expand Down Expand Up @@ -276,6 +284,7 @@ than sorted alphabetically, and printed in that order. Who does what:
| `surface_discussion` | summarise the thread, put the open question to the maintainer. **Never auto-park an open conversation** |
| `nudge_reporter` | one nudge, as the PO identity |
| `park` | move to *Backlog* — the chase went unanswered |
| `autonomous_analyze` | the tier-1 carve-out, computed from evidence not the board field: post `@claude-bot analyze` as the PO (`scripts/gh-agent.sh --as po issue comment <n> --body "@claude-bot analyze"`), after confirming no prior analyze on the issue. Fires even when a stale `Awaiting` would otherwise quiet the item, because `ready-for-analysis` already proves the log is in; stands down when the item has Stage 2 history (`analyzed` / `needs-human-review`) or the card holds `Awaiting: maintainer` (see Autonomous spend) |
| `set_awaiting` / `set_priority` / `triage_labels` | grooming debt: write the board field or label |
| `add_card` | open issue, no card on the board at all — add it to Project #1 first, **then** set `Priority`; until both exist it is unrankable and invisible to every board pass |
| `move_card` | the card sits somewhere the evidence does not support — always move it to match `column`, never re-derive `column` to match the card |
Expand Down Expand Up @@ -505,20 +514,32 @@ wrote, and hiding which decisions were the agent's. An automation decision
carries the automation's face. If this ever fails the gate, **that is the
finding** — report it; do not route around it with plain `gh`.

It fires on an item entering Analysis that meets the
tier-1 bar from `Verb: next` directly — labelled `bug`, opened by someone
other than the maintainer, with its debug log attached — **and that has no
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
"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`)
would keep matching every pass under `/loop`, firing Stage 2 again each time
at $0.50–2 a shot. Every other item entering Analysis gets a proposal
instead.
The rhythm pass surfaces the qualifying items as `autonomous_analyze`
(ranked with the other Analysis actions) — a check against the item's
**evidence**, not the board field: labelled `bug`, opened by someone other
than the maintainer, with its debug log attached. `ready-for-analysis` is the
debug-log proof and the no-prior-analyze guard in one: triage sets it only
when it has confirmed the log is attached, and Stage 2 removes it (replacing
it with `analyzed` or `needs-human-review`). The rule itself also refuses an
item carrying either Stage 2 label — triage re-stamps `ready-for-analysis`
on an edited issue even after an inconclusive run, so without that check the
spend would re-fire on items analysis has already settled. A stale `Awaiting`
field must not quiet it — #681/#680 was a `bug` + `ready-for-analysis` item
whose card still said `Awaiting: reporter`, and the old carve-out, evaluated
only on items the pass surfaced, never saw it. The carve-out overrides the
stale wait values (`reporter`, `upstream`, `discussion`) but stands down on
`Awaiting: maintainer`, where the loop is deliberately held for your
decision; and the same evidence makes the `park` / `nudge_reporter` /
`surface_discussion` chases stand down, so the pass never tells the PO to
bury what it just un-hid. Still confirm there is no prior `@claude-bot
analyze` comment already on the issue before posting — check 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: an in-flight analyze keeps
`ready-for-analysis` until it completes, so the action may re-propose during
that window. 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 "rank" into a tier. Every other item entering
Analysis gets a proposal instead.

## Close the loop

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- **A stale `Awaiting` field no longer hides a qualifying bug from autonomous analysis** — the tier-1 carve-out is now computed from the issue's own evidence instead of the board field. ([#681](https://github.com/johanzander/bess-manager/issues/681))
- **Backlog digest now links a PR to its issue on any `#N` reference, not just closing keywords** — a "Part of #N" PR no longer leaves its issue showing In Progress. ([#652](https://github.com/johanzander/bess-manager/issues/652))
- **Beta release changelog merges no longer absorb the new section into the previous one** — the merge is now resolved deterministically instead of by hand. ([#648](https://github.com/johanzander/bess-manager/issues/648))

Expand Down
165 changes: 165 additions & 0 deletions backend/tests/test_backlog_rhythm.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,171 @@ def test_ready_for_dev_is_reported_as_dispatchable(tmp_path: Path) -> None:
assert "dispatchable" in _actions_for(_run(tmp_path, [item]), 10)


# --- the autonomous Stage 2 carve-out --------------------------------------


def test_a_tier1_bug_is_surfaced_for_autonomous_analysis_even_when_awaiting_reporter(
tmp_path: Path,
) -> None:
"""#681: a stale `Awaiting: reporter` hid #680 from the autonomous Stage 2
carve-out, because the carve-out was only ever evaluated against items the
rhythm pass surfaced, and the stale field suppressed the item entirely.
The tier-1 check must run against the evidence (bug + ready-for-analysis +
non-maintainer author), never the board field — `ready-for-analysis` is
triage's own confirmation that the debug log is attached, so the item is
waiting on analysis, not the reporter. And the same evidence must suppress
the chases for that item: with the log attached the ball is NOT with the
reporter, so parking or nudging it in the same pass would bury the exact
bug the carve-out just un-hid."""
item = _item(
681,
labels=["bug", "bot-analyzed", "ready-for-analysis"],
author="ridax67",
awaiting="reporter",
awaiting_source="board",
awaiting_suggested="analysis",
last_comment=None,
)
actions = _actions_for(_run(tmp_path, [item]), 681)
assert "autonomous_analyze" in actions
assert "park" not in actions
assert "nudge_reporter" not in actions
assert "surface_discussion" not in actions


def test_a_qualifying_bug_fires_even_when_the_card_is_otherwise_quiet(
tmp_path: Path,
) -> None:
"""The carve-out must not depend on the item producing some other action
first — a fresh, reconciled, correctly-groomed card would otherwise be
silent and never reach the PO."""
item = _item(
682,
labels=["bug", "ready-for-analysis"],
author="ridax67",
column="Analysis",
board_status="Analysis",
awaiting=None,
priority="P2",
)
assert "autonomous_analyze" in _actions_for(_run(tmp_path, [item]), 682)


def test_a_bug_still_waiting_for_its_log_does_not_fire(tmp_path: Path) -> None:
"""`needs-debug-log` means the reporter still owes the bundle — not yet a
tier-1 bug, whatever the author."""
item = _item(
683,
labels=["bug", "needs-debug-log"],
author="ridax67",
awaiting="reporter",
awaiting_source="label",
)
assert "autonomous_analyze" not in _actions_for(_run(tmp_path, [item]), 683)


def test_a_bug_opened_by_the_maintainer_does_not_fire(tmp_path: Path) -> None:
item = _item(684, labels=["bug", "ready-for-analysis"], author="johanzander")
assert "autonomous_analyze" not in _actions_for(_run(tmp_path, [item]), 684)


def test_an_already_analyzed_bug_does_not_fire(tmp_path: Path) -> None:
"""`ready-for-analysis` is consumed by Stage 2 (replaced with `analyzed` or
`needs-human-review`), so an analysed item must not match again — otherwise
the pass would re-fire Stage 2 at $0.50-2 a shot under /loop."""
analyzed = _item(685, labels=["bug", "analyzed"], author="ridax67")
inconclusive = _item(686, labels=["bug", "needs-human-review"], author="ridax67")
result = _run(tmp_path, [analyzed, inconclusive])
assert "autonomous_analyze" not in _actions_for(result, 685)
assert "autonomous_analyze" not in _actions_for(result, 686)


def test_autonomous_analyze_ranks_with_the_analysis_column(tmp_path: Path) -> None:
"""Analysis-column work, so it sorts with the other Analysis actions — not
with the rank-9 catch-all an unnamed action would fall to."""
item = _item(687, labels=["bug", "ready-for-analysis"], author="ridax67")
result = _run(tmp_path, [item])
action = next(a for a in result["actions"] if a.get("issue") == 687)
assert action["action"] == "autonomous_analyze"
assert action["rank"] == 5


def test_a_tier1_bug_at_nudge_threshold_is_analyzed_not_nudged(
tmp_path: Path,
) -> None:
"""The same stale-field suppression at the nudge boundary: with the debug
log attached, `nudge_reporter` would chase the reporter for something they
already supplied — the two actions contradict, so the chase must stand
down whenever the carve-out fires."""
item = _item(
688,
labels=["bug", "ready-for-analysis"],
author="ridax67",
awaiting="reporter",
last_comment=_comment(14),
)
actions = _actions_for(_run(tmp_path, [item]), 688)
assert "autonomous_analyze" in actions
assert "nudge_reporter" not in actions
assert "park" not in actions


def test_a_tier1_bug_with_stage2_history_does_not_fire(tmp_path: Path) -> None:
"""`ready-for-analysis` is consumed by Stage 2 (replaced with `analyzed` or
`needs-human-review`), but triage re-runs on an edited issue and re-stamps
`ready-for-analysis` even after an inconclusive run, so the two can coexist
on one item. The carve-out is for items with NO Stage 2 history at all —
an item analysis has already settled must not re-fire the $0.50-2 spend
every /loop tick, whatever labels have since accrued."""
analyzed = _item(
689, labels=["bug", "ready-for-analysis", "analyzed"], author="ridax67"
)
inconclusive = _item(
690,
labels=["bug", "ready-for-analysis", "needs-human-review"],
author="ridax67",
)
result = _run(tmp_path, [analyzed, inconclusive])
assert "autonomous_analyze" not in _actions_for(result, 689)
assert "autonomous_analyze" not in _actions_for(result, 690)


def test_a_tier1_bug_awaiting_the_maintainer_escalates_instead_of_firing(
tmp_path: Path,
) -> None:
"""`Awaiting: maintainer` is the one deliberate board hold — the loop
cannot advance without a decision, so the spend is not the point. The
carve-out must stand down and let the escalation be the loudest (rank 0)
action."""
item = _item(
691,
labels=["bug", "ready-for-analysis"],
author="ridax67",
awaiting="maintainer",
awaiting_source="board",
)
actions = _actions_for(_run(tmp_path, [item]), 691)
assert "escalated" in actions
assert "autonomous_analyze" not in actions


def test_a_tier1_bug_in_discussion_is_analyzed_not_surfaced(tmp_path: Path) -> None:
"""`discussion` is a "someone else owes us" state like `reporter`, so it is
stale-field material too: the evidence says ready, so the carve-out fires
and the surface-to-maintainer action stands down rather than contradicting
it."""
item = _item(
692,
labels=["bug", "ready-for-analysis"],
author="ridax67",
awaiting="discussion",
last_comment=_comment(40),
)
actions = _actions_for(_run(tmp_path, [item]), 692)
assert "autonomous_analyze" in actions
assert "surface_discussion" not in actions


# --- the PR half: reaching a READY PR ------------------------------------


Expand Down
Loading
Loading