This document covers two related features that ship together: beflow-owned PR
creation (pr.owner: "beflow") and the post-run policy gate (policy).
Both are opt-in and affect only autonomous implement runs with a worktree.
The agent handles the full GitHub workflow. It commits, pushes, and opens the
pull request using whatever tools it has available (typically gh). beflow
writes back the PR URL from the agent's structured report. This is the default
behavior and requires no config change.
The agent commits and pushes its branch, but does not open a PR. beflow takes over from there: it opens a draft PR, runs the quality gate, evaluates post-run policy, enriches the PR body, and then either marks the PR ready, leaves it as a draft requiring human approval, or closes it. The board is updated to reflect the outcome in every case.
Set this globally or per project:
"pr": {
"owner": "beflow",
"baseBranch": "auto"
}baseBranch: "auto" (the only supported value) tells beflow to detect the
repo's default branch at runtime. The baseBranch field accepts any string;
"auto" is the conventional value used in config.example.json.
When owner: "beflow" is active, an autonomous implement run proceeds through
these steps:
-
Decision gate — if the issue carries a
needs-decisionlabel, it is parked to Needs Input immediately and a hold record is written. The label is the opt-in; removing it releases the issue back to Todo. -
Input-quality gate — if the issue body is below the configured
minBodyCharsthreshold, it is parked to Needs Input before any worktree is created, so no agent run is burned on an under-specified issue. -
Policy preflight — the configured policy is run against the coarse file paths the issue declares in its title and body, before any worktree is created. If that declared scope hits a
blockrule, the issue is parked to Needs Input immediately. See The policy preflight below; the post-run gate (step 9) remains authoritative. -
Create worktree — beflow creates an isolated git worktree at
beflow/<key>(underworktrees.dir). The agent runs inside it. -
Agent run — the agent commits and pushes its branch. The contract explicitly instructs it not to open a PR; that step belongs to beflow.
-
No-op check — after the agent reports
done, beflow checks whether the branch has any commits ahead of the base. If not (empty output), the run is parked as failed and the worktree is kept for inspection. -
Open draft PR — beflow opens a draft PR titled
[beflow] <KEY>: <title>from the pushed branch. The draft is the review artifact for the rest of the pipeline. -
Quality gate — if
qualityGate.commandsare configured, they run in the worktree. On RED, the agent is re-prompted with the failing output up toqualityGate.maxReworktimes (default 1;0disables auto-rework), re-checking after each. If it is still RED once the rework budget is exhausted, the run is parked as failed (the draft PR is kept).Baseline pinning — by default the gate runs against the worktree's own test tree, which an autonomous agent could weaken (delete or soften an assertion) in the same branch so the gate self-grades green. When
qualityGate.baselineTestGlobsis set, beflow pins the gate's definition-of-passing to the target branch: before each gate run it restores the changed files matching those globs from the base branch into the worktree, runs the commands against the run's implementation plus the baseline tests, then restores the worktree's own files. A change can no longer grade itself against tests it just modified. This is orthogonal to policy blast-radius: AGENTOWNERS governs which paths a change may touch, while baseline pinning governs which tests judge the change. The agent's test edits remain on the branch and are reviewed normally; only the automated gate run uses the baseline. Pinning engages only for beflow-owned runs (where the base branch and diff are known) and is off when the globs are unset. -
Post-run policy — beflow evaluates the configured policy over the diff and decides the PR's fate (see Policy outcomes below).
-
Write back — the board is updated and a comment is posted with the run summary and PR link.
The post-run gate (step 9) is authoritative: it judges the run's actual diff. But that judgement only happens after beflow has built a worktree and spent a full agent run (30–40 min) producing the diff. The preflight (step 3) is a cheap fail-fast that runs the same resolved policy earlier, against the file paths the issue declares, so a task that would certainly be blocked is parked to Needs Input before any of that work begins.
It is deliberately conservative — a false block is harmful, so the preflight errs toward proceeding:
- Coarse paths only. The only signal is path-like tokens in the issue's
title and body — a token with a path separator and a file extension
(
infra/deploy.yaml,tests/x.test.ts) or a dotfile-rooted path (.github/workflows/ci.yml). Prose, URLs, and bare identifiers are ignored. - Block-only short-circuit. The preflight parks only on a confident
blockdecision.require_approvalandallowproceed normally; the post-run gate decides those over the real diff. - Empty signal proceeds. If the issue declares no path-like tokens, the preflight does nothing — it never short-circuits on no evidence.
- Same engage conditions as the post-run gate. It runs only for an
autonomous
implementrun with an active policy (evaluatoris notoff). - Same evaluator. It reuses
resolvePolicy+evaluatePolicy— one resolver, two call points. For theagentownersevaluator it reads.github/AGENTOWNERSfrom the base repo (which exists pre-worktree).
The honest caveat: the preflight is only as sharp as the paths an issue declares. An issue that hides its blast radius in prose will sail past it and be caught by the authoritative post-run gate instead. The preflight never weakens that gate; it only short-circuits the unambiguous cases early.
On the proceed path — after the block short-circuit, when the declared scope is
about to be allowed through — the preflight also looks backward. It reads the
decision log and, when the issue's declared paths exactly overlap
paths a prior run in the same project sent to block or
require_approval, it logs a heads-up such as:
beflow: APP-42 — heads up: declared scope overlaps paths a prior run sent to block (APP-7: infra/secrets.tf); proceeding — the live policy gate remains authoritative
This is purely advisory — it never blocks or parks. The live policy gate stays authoritative; history only surfaces a warning. Matching is exact on repo-relative paths (no fuzzy / directory-prefix matching), it is scoped to the current project, and lookback is bounded to recent history. A missing or partial log simply yields no warning.
| Decision | What beflow does |
|---|---|
allow |
Enriches the PR body with the agent's summary, marks the PR ready for review, and moves the issue to In Review. |
require_approval |
Enriches the PR body, leaves the PR as a draft, moves the issue to In Review, and posts an awaits-approval note asking a human to approve and mark it ready. |
block |
Closes the PR (keeping the branch for review and forensics), then routes the issue to Needs Input with a comment explaining the block reason. |
A block or require_approval decision is pre-PR governance: it runs before
the PR is visible to reviewers. It complements (and does not replace) GitHub
branch protection rules.
On a finished done run the agent MAY emit a change receipt: a structured
statement of intent and risk surfaces alongside its report. It is carried
through the run record and handed to the post-run gate so the gate can judge what
the change is trying to do, not only which files it touched. The receipt is
additive intent — path rules remain the floor. The globs and agentowners
evaluators ignore it entirely (they decide on paths); only the command
evaluator receives it (on stdin, in the change context) and may judge it.
The agent emits the receipt inside its beflow-report block (see
Prompts and contracts). beflow knows the issue id and branch, so
the agent does not repeat them. Implement runs that change code SHOULD include
a receipt; triage and spec runs that change nothing may omit it.
| Field | Required | Meaning |
|---|---|---|
intent |
yes | One or two lines: what the change does and why. |
riskSurfaces |
yes | The risk surfaces the change touches (taxonomy below). |
surfaceNotes |
no | A policy-relevant note per surface, keyed by surface name. |
filesTouched |
no | The files the agent claims it changed (beflow still diffs). |
testsRun |
no | Tests or commands the agent ran. |
uncertainty |
no | What the agent is unsure about. |
nextDecision |
no | The next human decision needed, if any. |
Risk-surface taxonomy:
| Surface | Meaning |
|---|---|
app |
Application/product code and business logic. |
deps |
Dependencies, lockfiles, package manifests. |
infra |
Infrastructure, deployment, runtime config. |
auth |
Authentication, authorization, secrets, access control. |
data |
Schemas, migrations, data handling, persistence. |
ci |
CI/CD pipelines and build automation. |
The gate evaluates the receipt and the diff: a command evaluator can return
require_approval or block informed by riskSurfaces (e.g. require approval for
any change that touches auth), while changedFiles still bounds the decision.
Every post-run policy decision is recorded as one append-only event in a local
canonical decision log — a sibling of the runs dir, default
~/.beflow/decisions/decisions.ndjson (override with decisions.dir). One NDJSON
line is written per decision, at decision time, before any of the writeback
branches run (and before the allow path's run-record GC). The tracker comment is
ephemeral and the run record is deleted on a clean writeback, so for an allow
this log is the only structured trace of why the change was permitted.
Each line is a self-contained DecisionEvent:
{
"schemaVersion": 1,
"decisionId": "a1b2c3d4-…",
"runId": "APP-42@2026-06-20T00:00:00.000Z",
"key": "APP-42",
"prUrl": "https://github.com/acme/app/pull/99",
"decision": "allow",
"evaluator": "globs",
"matchedRules": [{ "decision": "allow", "paths": ["src/**"] }],
"changedFiles": ["src/api/auth.ts"],
"reason": "rule decision=allow paths=src/**",
"timestamp": "2026-06-20T00:00:00.000Z",
"changedFilesHash": "…sha256…",
"decisionInputHash": "…sha256…",
"evidence": {
"intent": "harden token signing",
"riskSurfaces": ["app", "auth"],
"surfaceNotes": { "auth": "no change to token signing" }
}
}matchedRules is the structured companion to the flattened reason string: it
carries every rule that fired (its decision and matched paths/globs), not just the
winner. changedFilesHash and decisionInputHash are SHA-256 digests that make
the log tamper-evident for free. evidence captures the agent's self-declared
intent, riskSurfaces, and (when present) surfaceNotes from its change
receipt — the reviewer-facing "what/why/risk", which also renders into the
approval receipt comment; it is absent when the agent emitted no receipt.
approver is reserved for a later issue and is absent today.
The log is append-only: beflow never rewrites or truncates it. It is written
through a DecisionSink adapter — a stable interface with swappable
implementations. The local NDJSON sink is paired (via a CompositeSink) with a
best-effort tracker receipt sink that posts a human-readable summary of each
decision as a comment on the issue. The audit write always lands first; a tracker
outage logs and is swallowed, so it can never fail the run or lose the NDJSON
event. Set decisions.comment to false to opt out of the receipt comment. The
receipt body is not a forced format — it is a fully overridable
decision-receipt.md
prompt template. Object-storage or SIEM sinks are future drop-ins behind the same interface and the
same event shape, not a change to the record.
Applies globally unless a project-level pr block overrides it wholesale.
"pr": {
"owner": "agent",
"baseBranch": "auto"
}| Field | Type | Description |
|---|---|---|
owner |
"agent" | "beflow" |
Who opens the PR. "agent" is the default. |
baseBranch |
string |
The PR base branch. Use "auto" to detect the repo default at runtime. |
Applies globally unless a project-level policy block overrides it wholesale.
"policy": {
"evaluator": "globs",
"onBlock": "comment",
"rules": [
{ "paths": ["infra/**", "**/*.tf"], "decision": "require_approval" },
{ "paths": [".github/**"], "decision": "block" }
]
}| Field | Type | Description |
|---|---|---|
evaluator |
"globs" | "agentowners" | "command" | "off" |
How the policy is evaluated. |
rules |
Rule[] |
Ordered list of match rules (used when evaluator is "globs"). |
agentownersPath |
string |
Path to the AGENTOWNERS file (used when evaluator is "agentowners"). Default: .github/AGENTOWNERS. |
command |
string[] |
Command + args to invoke (used when evaluator is "command"). |
onBlock |
"comment" |
Side-effect on a block decision. "comment" posts the block reason to the issue. |
All matching rules are collected and the most restrictive decision wins
(block > require_approval > allow), so rule order does not matter. A rule
with no paths or agent filter matches everything and acts as a catch-all
default.
| Field | Type | Description |
|---|---|---|
paths |
string[] |
Glob patterns matched against the files changed in the diff. |
agent |
string |
Match only when this agent ran the job. |
decision |
"allow" | "require_approval" | "block" |
Required. The outcome when this rule matches. |
paths are full-path globs matched against the changed file paths, so a rule
for a directory needs infra/**, not infra. A rule that matches nothing simply
does not fire — the change falls through to the next rule, and ultimately toward
allow if no rule matches.
"policy": {
"evaluator": "globs",
"onBlock": "comment",
"rules": [
{ "paths": ["infra/**", "**/*.tf"], "decision": "require_approval" },
{ "paths": [".github/**"], "decision": "block" },
{ "decision": "allow" }
]
}Rules are evaluated with most-restrictive-wins across all matched rules. The final catch-all rule (no filters) ensures every run gets an explicit decision.
The agentowners evaluator reads a CODEOWNERS-style file and runs the same
most-restrictive-wins engine as globs. It is the built-in alternative to
wiring a custom command hook for path-based ownership policies.
"policy": {
"evaluator": "agentowners",
"agentownersPath": ".github/AGENTOWNERS"
}File format — one rule per line: <path-glob> <decision> [agent]. #
starts a comment; blank lines are ignored. decision must be one of block,
require_approval, or allow. An optional third column scopes the rule to a
specific agent name; extra columns are malformed.
# AGENTOWNERS — most-restrictive wins: block > require_approval > allow
package.json block
**/*.lock block
infra/** require_approval
.github/** require_approval
src/** allow claude
* allow
Missing file — if the file does not exist at the resolved path, the
evaluator fails closed and returns require_approval (not allow): you
opted into a gate, so beflow won't silently ship. The run still opens a PR, but
it stays a draft awaiting human sign-off until you create the file (via
beflow setup/update, or by hand). doctor warns when an enabled gate's file
is missing.
Malformed file — an invalid decision token or a line with extra columns causes a hard error: the run is parked as failed (fails closed). A broken policy file never silently degrades to an allow.
agentownersPath resolution — a relative path is resolved against the
run's worktree root; an absolute path is used as-is. Pointing agentownersPath
at an absolute path outside the repo lets you keep the policy in a location the
agent cannot edit in the same change it governs. A relative in-repo path (the
default .github/AGENTOWNERS) is editable by the agent in the same change — if
that matters for your threat model, use an out-of-repo absolute path, or switch
to evaluator: "command" where you own the trust call entirely.
The gate is opt-in and never written by default. beflow only scaffolds an
AGENTOWNERS file when you have actually selected the gate
(policy.evaluator = "agentowners", global or per-project). With any other
evaluator — including the default off — setup/update write nothing into
your repos.
When the gate is selected, setup/update create a starter file at the
configured agentownersPath (the exact location the evaluator reads), in every
repo the project maps, if one is not already present. Existing files are never
overwritten. The starter is intentionally empty — just header comments and
examples, no active rules — so beflow imposes no approval policy you didn't
write (no rule ⇒ allow). Add rules deliberately:
# beflow control-plane AGENTOWNERS
# tests/** require_approval # changes to tests need a human sign-off
# .github/** require_approval # CI/workflow changes need a human sign-off
# migrations/** block # never let an agent touch DB migrations
If you want a gate with no files in the repo at all, use evaluator: "globs"
with inline rules instead — same engine, zero repo footprint.
Use "command" to implement arbitrary policy logic behind a single hook.
beflow is not opinionated about the format of the ownership file; the hook can
implement whatever convention suits the repo.
"policy": {
"evaluator": "command",
"command": ["node", "scripts/policy-check.js"]
}beflow invokes the command and passes the change context as JSON on stdin:
{
"issueKey": "APP-42",
"agent": "claude",
"jobKind": "implement",
"repo": "main_repo",
"baseBranch": "main",
"changedFiles": ["src/api/auth.ts", "infra/rds.tf"],
"receipt": {
"intent": "add a login route",
"riskSurfaces": ["app", "auth"],
"surfaceNotes": { "auth": "no change to token signing" }
}
}receipt is the agent's change receipt and is present only when
the agent emitted one. The command may judge intent and riskSurfaces in addition
to changedFiles — but changedFiles remains the floor.
The command must write a single JSON object to stdout and exit 0:
{ "decision": "require_approval", "reason": "infra/rds.tf requires ops approval" }decision must be one of "allow", "require_approval", or "block".
reason is included in the block comment and the tracker writeback when
provided. A non-zero exit or unparseable output causes the run to be parked as
failed (the worktree and draft PR are kept for inspection).
A projects.<KEY>.pr or projects.<KEY>.policy block replaces the
corresponding global block wholesale — it does not merge with it. Use this when
a project needs different PR defaults or policy rules from the rest of the
workspace. For the full priority order (including builtins and how block-level
replacement differs from the field-level cascade used by agent / runMode),
see the unified resolution reference.
"projects": {
"INFRA": {
"pr": { "owner": "beflow", "baseBranch": "auto" },
"policy": {
"evaluator": "globs",
"onBlock": "comment",
"rules": [
{ "decision": "require_approval" }
]
}
},
"OPS": {
"policy": {
"evaluator": "agentowners",
"agentownersPath": ".github/AGENTOWNERS"
}
}
}The beflow-owned PR pipeline and the post-run policy gate engage only for
autonomous (--auto) implement runs that use a worktree. They have no effect
on:
- Supervised (
--attend) or open (--open) runs — a human is present in those modes and governs the outcome directly. - Non-
implementjob kinds (spec,triage,review). - Runs where
pr.ownerresolves to"agent"(the default).
Policy block and require_approval decisions operate before the PR is visible
to GitHub reviewers and complement (not replace) GitHub branch protection rules.