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
78 changes: 65 additions & 13 deletions .github/labels.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,67 @@
[
{"name": "agent-task", "color": "5319E7", "description": "Written with the agent-task form; not approval for agents (that is agent-ready)"},
{"name": "agent-ready", "color": "0E8A16", "description": "Triaged spec: an agent may take it. Set by a maintainer or the maintenance routine, not a template"},
{"name": "agent-working", "color": "1D76DB", "description": "An agent has claimed this issue (the claim comment names the branch)"},
{"name": "agent-pr", "color": "5319E7", "description": "Pull request opened by an agent"},
{"name": "awaiting-approval", "color": "FBCA04", "description": "Legacy: a PR a person must decide on; routine T2/T3 PRs no longer wait for a click"},
{"name": "self-test", "color": "C5DEF5", "description": "Filed by the product self-test routine (issues only)"},
{"name": "needs-info", "color": "D4C5F9", "description": "Waiting for the reporter or a maintainer decision"},
{"name": "needs-human", "color": "B60205", "description": "The agent loop stopped; a person must decide (reason in the last comment)"},
{"name": "hold", "color": "000000", "description": "A person froze this issue or PR; agents and automation leave it alone"},
{"name": "risk:T0", "color": "C2E0C6", "description": "T0 (docs only). Never by hand; advisory, the merge gate recomputes it"},
{"name": "risk:T1", "color": "BFD4F2", "description": "T1 (client, tests, generated files). Advisory; the merge gate recomputes it"},
{"name": "risk:T2", "color": "FBCA04", "description": "T2 (host/plugin code or an unnamed path). Advisory; the merge gate recomputes it"},
{"name": "risk:T3", "color": "D93F0B", "description": "T3 (a PASS at the tip and green CI). Advisory; the merge gate recomputes it"}
{
"name": "agent-task",
"color": "5319E7",
"description": "Written with the agent-task form; not approval for agents (that is agent-ready)"
},
{
"name": "agent-ready",
"color": "0E8A16",
"description": "Triaged spec: an agent may take it. Set by a maintainer or the maintenance routine, not a template"
},
{
"name": "agent-working",
"color": "1D76DB",
"description": "An agent has claimed this issue (the claim comment names the branch)"
},
{
"name": "agent-pr",
"color": "5319E7",
"description": "Pull request opened by an agent"
},
{
"name": "awaiting-approval",
"color": "FBCA04",
"description": "Legacy: a PR a person must decide on; routine T2/T3 PRs no longer wait for a click"
},
{
"name": "self-test",
"color": "C5DEF5",
"description": "Filed by the product self-test routine (issues only)"
},
{
"name": "needs-info",
"color": "D4C5F9",
"description": "Waiting for the reporter or a maintainer decision"
},
{
"name": "needs-human",
"color": "B60205",
"description": "The agent loop stopped; a person must decide (reason in the last comment)"
},
{
"name": "hold",
"color": "000000",
"description": "A person froze this issue or PR; agents and automation leave it alone"
},
{
"name": "risk:T0",
"color": "C2E0C6",
"description": "T0 (docs only). Advisory; the controller labels it, the merge gate recomputes it"
},
{
"name": "risk:T1",
"color": "BFD4F2",
"description": "T1 (client, tests, generated). Advisory; the controller labels it, the merge gate recomputes it"
},
{
"name": "risk:T2",
"color": "FBCA04",
"description": "T2 (host/plugin code or unknown). Advisory; the controller labels it, the merge gate recomputes it"
},
{
"name": "risk:T3",
"color": "D93F0B",
"description": "T3 (PASS at the tip, green CI). Advisory; the controller labels it, the merge gate recomputes it"
}
]
25 changes: 22 additions & 3 deletions .github/scripts/risk-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,14 @@ export function createGate(rules) {
if (ORDER.indexOf(t) > ORDER.indexOf(tier)) tier = t
if (reason) why.push(reason)
}
// Every path the rules were asked about, with the tier it got: the machine-readable line at
// the end of the report is built from this, not from a second walk over the rules.
const basis = []
if (truncated || files.length >= MAX_FILES) bump('T3', `file list may be incomplete (${files.length} files)`)
for (const f of files) {
for (const name of [f.filename, f.previous_filename].filter(Boolean)) {
const t = tierOf(name)
basis.push([name, t])
bump(t, t === 'T3' ? `path ${name}` : null)
}
if (isGenerated(f.filename)) continue // generated files are verified by `patches check`, not read here
Expand All @@ -81,13 +85,28 @@ export function createGate(rules) {
if (lim && (lines > lim.lines || real.length > lim.files)) {
bump('T3', `size ${lines} lines / ${real.length} files > ${lim.lines} / ${lim.files} allowed for ${tier}`)
}
return { tier, why: [...new Set(why)], lines, files: real.length, generated: files.length - real.length }
const seen = new Map()
for (const [path, t] of basis) if (!seen.has(path)) seen.set(path, t)
const ordered = [...seen].map(([path, t]) => ({ path, tier: t }))
.sort((a, b) => ORDER.indexOf(b.tier) - ORDER.indexOf(a.tier) || a.path.localeCompare(b.path))
return { tier, why: [...new Set(why)], lines, files: real.length, generated: files.length - real.length, basis: ordered }
}

return { tierOf, judge }
}

export function render({ tier, why, lines, files, generated }, meaning) {
// The one machine-readable line the job summary ends with (#609): the tier and the paths that
// set it, from the same judge() result the human-readable report is built from, so a reviewer can
// quote a tier instead of guessing one.
export const SUMMARY_LIMIT = 10
export function summaryLine({ tier, basis = [] }) {
const shown = basis.slice(0, SUMMARY_LIMIT).map((b) => `${b.path}=${b.tier}`)
const rest = basis.length > SUMMARY_LIMIT ? `, +${basis.length - SUMMARY_LIMIT} more` : ''
return basis.length === 0 ? `risk-tier: ${tier}; basis: none (no changed files)` : `risk-tier: ${tier}; basis: ${shown.join(', ')}${rest}`
}

export function render(result, meaning) {
const { tier, why, lines, files, generated } = result
const out = [
`### Risk tier: ${tier}`,
'',
Expand All @@ -99,7 +118,7 @@ export function render({ tier, why, lines, files, generated }, meaning) {
out.push('', 'Why:', ...why.slice(0, 20).map((w) => `- ${w}`))
if (why.length > 20) out.push(`- ... and ${why.length - 20} more`)
}
out.push('', '_Report only (Phase 0): this does not block, label or merge anything. Rules: `.github/agent/risk-rules.json`; see `docs/autonomous-dev.md`._')
out.push('', '_Report only (Phase 0): this does not block, label or merge anything. Rules: `.github/agent/risk-rules.json`; see `docs/autonomous-dev.md`._', '', summaryLine(result))
return out.join('\n')
}

Expand Down
23 changes: 22 additions & 1 deletion .github/scripts/risk-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { globToRe, createGate, render, loadRules } from './risk-gate.mjs'
import { globToRe, createGate, render, summaryLine, loadRules } from './risk-gate.mjs'

const rules = loadRules(new URL('../agent/risk-rules.json', import.meta.url))
const gate = createGate(rules)
Expand Down Expand Up @@ -117,6 +117,27 @@ test('render: names the tier, the reasons, and says it is report only', () => {
assert.match(text, /does not block, label or merge/)
})

test('summaryLine: one machine-readable line with the tier and the paths that set it', () => {
const r = gate.judge([file('AGENTS.md'), file('plugins/social/src/social.js')])
assert.equal(summaryLine(r), 'risk-tier: T3; basis: AGENTS.md=T3, plugins/social/src/social.js=T3')
})

test('summaryLine: highest tier first, and a long basis is capped', () => {
const files = Array.from({ length: 12 }, (_, i) => file(`plugins/social/src/p${i}.js`))
const line = summaryLine(gate.judge([...files, file('docs/x.md')]))
assert.match(line, /^risk-tier: T3; basis: /)
assert.match(line, /\+3 more$/)
})

test('summaryLine: no changed files says so instead of listing nothing', () => {
assert.equal(summaryLine(gate.judge([])), 'risk-tier: T0; basis: none (no changed files)')
})

test('render: the machine-readable line is the last line of the report', () => {
const r = gate.judge([file('AGENTS.md'), file('core/src/host/groups.js')])
assert.equal(render(r, meaning).trimEnd().split('\n').at(-1), summaryLine(r))
})

test('rules file is consistent', () => {
for (const t of ['T0', 'T1', 'T2', 'T3']) assert.ok(rules.tiers[t].meaning && rules.tiers[t].paths.length, t)
assert.ok(['T2', 'T3'].includes(rules.unknownPathTier), 'unknown paths must not default below T2')
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/risk-tier.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
name: Risk tier (report only)

# Phase 0 of docs/autonomous-dev.md. Computes the risk tier (T0-T3) of a pull request and writes
# it to the job summary. It is NOT a required check, never blocks, labels, comments or merges.
# it to the job summary, ending with one machine-readable line (`risk-tier: T2; basis: <path>=<tier>, …`)
# that a review can quote (#609). It is NOT a required check, never blocks, labels, comments or merges.
# Keep it out of the branch protection's required checks and out of the `tests-ok` needs list.
#
# Safety: `pull_request` (not pull_request_target), read-only token, no secrets. The script and
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Decided by the owner (Danyang, GitHub `Birfy`) on 2026-10-18/19. Four scheduled
- **State:** `needs-info` (spec unclear), `needs-human` (needs a design decision from the owner; the comment says what), `awaiting-approval` (legacy: a PR a person must decide on; routine T2/T3 PRs no longer wait for a click), `agent-ready`, `agent-working`, `agent-pr`, `hold`.
- **Controller-managed, never set by hand:** `risk:T0` to `risk:T3`, `agent-task`, `status:done`.
- **A `risk:T*` label is advisory, and never the tier a decision uses.** The controller is a poll: GitHub delays scheduled runs by hours (2.5–6 h spacing observed on this repository, [#629](https://github.com/ThinkFlowLab/FlyBot/issues/629)), and a fork PR's `pull_request` run has a read-only token, so it cannot label one at all. An open PR can therefore carry no tier until the next run, and a PR opened and merged between two runs never gets one. Read a missing label as "not polled yet" — never as T0/T1 and never as a reason to block. The tier that decides is the one `merge-gate` recomputes from the diff at merge time, and `risk-tier.yml` reports the same tier on every PR.
- **The computed tier is readable from the job summary.** `risk-tier.yml` ends its summary with one machine-readable line — `risk-tier: T3; basis: AGENTS.md=T3, …` — built by the same `judge()` call that prints the report, so the line and the prose cannot disagree. Quote that line instead of guessing a tier. The workflow itself never writes a label, and no review transcribes a tier onto a PR: the controller copies it on its next poll (#609).
- `hold` means automation leaves the item alone.

### Triage: when an issue is `agent-ready`
Expand Down
1 change: 1 addition & 0 deletions docs/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ The script and `controller.json` come from the default branch (`checkout` of `de
- Events caused by `GITHUB_TOKEN` start no workflows, so nothing chains from its labels; it runs on the schedule.
- GitHub stops scheduled workflows of a public repository after 60 days without repository activity.
- **A missing `risk:T*` is not a tier.** GitHub runs scheduled workflows late under load: the 30-minute cron produced runs 2.5–6 hours apart on this repository (48 runs, all `success`, none skipped — #629), so a label may arrive hours after the PR is opened, and a PR opened and merged between two runs gets none at all. A fork PR cannot be labelled by its own `pull_request` run either (read-only `GITHUB_TOKEN`), which is why the external ones (#611, #619) lack it by construction. Nothing depends on the label: `merge-gate` recomputes the tier from the diff with the base copy of `risk-gate.mjs`, and `risk-tier.yml` reports the same tier on every PR, forks included. Read an absent label as "the poll has not seen this PR yet" — never as T0/T1 and never as a reason to block.
The tier is still auditable without the label: `risk-tier.yml` ends its job summary with one machine-readable line, `risk-tier: T3; basis: <path>=<tier>, …`, computed by the same `judge()` that prints the prose report (#609), and `merge-gate` recomputes the same tier from the diff at queue time.
- 50 PRs and 50 issues per label per run (`controller.json`); about 3 API calls per PR per run.

## Try it by hand (read-only)
Expand Down
Loading