diff --git a/.github/labels.json b/.github/labels.json index 9a614462a..fc423bb83 100644 --- a/.github/labels.json +++ b/.github/labels.json @@ -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" + } ] diff --git a/.github/scripts/risk-gate.mjs b/.github/scripts/risk-gate.mjs index 4d1da8e5b..6131765bd 100644 --- a/.github/scripts/risk-gate.mjs +++ b/.github/scripts/risk-gate.mjs @@ -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 @@ -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}`, '', @@ -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') } diff --git a/.github/scripts/risk-gate.test.mjs b/.github/scripts/risk-gate.test.mjs index ebdd97a29..37d4ea5e3 100644 --- a/.github/scripts/risk-gate.test.mjs +++ b/.github/scripts/risk-gate.test.mjs @@ -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) @@ -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') diff --git a/.github/workflows/risk-tier.yml b/.github/workflows/risk-tier.yml index d4772ad69..dae759270 100644 --- a/.github/workflows/risk-tier.yml +++ b/.github/workflows/risk-tier.yml @@ -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: =, …`) +# 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 diff --git a/AGENTS.md b/AGENTS.md index 0b75b6dce..79e7696cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/docs/controller.md b/docs/controller.md index 431f251bb..eaedfd891 100644 --- a/docs/controller.md +++ b/docs/controller.md @@ -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: =, …`, 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)