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
14 changes: 8 additions & 6 deletions .stamphog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,21 @@ A folder may carry an `AGENT_APPROVALS.md` with a `stamphog:` frontmatter block
Resolution:

- Every `AGENT_APPROVALS.md` at or above a changed file governs it: guidance accumulates outermost first, and a child file adds to its ancestors rather than replacing them.
- For the delegated `size_gate.max_files`, the nearest file on the chain with a valid grant wins for its files (within the contract ceiling); files whose chain grants nothing belong to the global pool.
- For the delegated `size_gate.max_files` and `size_gate.max_lines`, the nearest file on the chain with a valid grant wins for its files (within the contract ceilings). Each key resolves on its own: a folder that grants only one key leaves its files to the nearest ancestor grant of the other key, or to the global pool when no ancestor grants it. Files whose chain grants nothing belong to the global pool.
- The frontmatter is a positive allow-list: only keys named in the `overrides` contract in `policy.yml` are read, within their ceilings. Anything else (unknown key, out-of-bounds value, unparseable frontmatter) invalidates the whole file - frontmatter and prose. An invalid file contributes nothing itself, but it does not cancel its ancestors: files under it still ride an ancestor's grant, or fall to the global pool if the chain grants nothing. Rationale: an author who can write an invalid file could equally delete it, so treating invalid as absent grants no extra power, and every `AGENT_APPROVALS.md` edit is human-reviewed via the `stamphog_policy` deny anyway.
- The prose is untrusted advisory guidance. It is sanitized, length-capped, and injected inside the reviewer prompt's untrusted region; it can never override the deny rules or the refusal criteria.

### Mixed PRs get mixed leniency

Each scope's files are counted against that scope's own file ceiling, so a grant covers exactly the files that resolve to it (the nearest valid grant on their chain) and nothing else.
Each scope's files are counted against that scope's own ceiling, so a grant covers exactly the files that resolve to it (the nearest valid grant of that key on their chain) and nothing else.
Example: a PR changing 30 files under `products/visual_review/` (ceiling 50) plus 19 files elsewhere (global ceiling 20) passes, because each budget fits.
Add a 21st global file and the PR is denied for the global budget, no matter how much headroom the folder still has.
Files whose chain grants no valid `max_files` (no folder file, prose-only, or only invalid grants) count against the global budget, so splitting files across pseudo-scopes can never inflate the allowance.
The line ceiling stays a single global total; it is not delegable.
Files whose chain grants nothing (no folder file, prose-only, or only invalid grants) count against the global budget, so splitting files across pseudo-scopes can never inflate the allowance.
Lines follow the same rule: a scope's substantive lines are counted against that scope's own line ceiling, and the global pool's lines against the global line ceiling.
The two ceilings are budgeted separately, so a folder that raises only the line ceiling still counts its files against the one global file budget.
That keeps a one-key grant from opening a second budget for the key it never asked for.

## Delegation contract

The set of keys a folder file may override lives under `overrides` in `policy.yml` (currently just `size_gate.max_files`, ceiling 50).
deny, allow, dismiss, tiers, and `size_gate.max_lines` are non-delegable by construction - they are absent from the contract and cannot be granted from a folder file.
The set of keys a folder file may override lives under `overrides` in `policy.yml` (currently `size_gate.max_files`, ceiling 50, and `size_gate.max_lines`, ceiling 1000).
deny, allow, dismiss, and tiers are non-delegable by construction - they are absent from the contract and cannot be granted from a folder file.
2 changes: 2 additions & 0 deletions .stamphog/policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ tiers:
overrides:
'size_gate.max_files':
ceiling: 50
'size_gate.max_lines':
ceiling: 1000
# Author-familiarity signal - judgment layer only, never a gate. Non-delegable
# (absent from `overrides`). STRONG = blame overlap ≥ min_blame_overlap_pct;
# MODERATE = both of its keys satisfied; else NONE.
Expand Down
11 changes: 11 additions & 0 deletions products/desktop/AGENT_APPROVALS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
stamphog:
size_gate:
max_lines: 1000
---

Desktop PRs are often tall: one feature usually lands across packages/core, packages/ui and the app in a single PR, so substantive line counts run past the global ceiling without adding review risk.
A larger line count alone is not a red flag here, so it may be reviewed more leniently than the global default.

Correctness concerns get the usual full scrutiny: authentication, data handling, and CI or workflow changes are judged exactly as they are anywhere else.
This guidance only relaxes the line ceiling; it never lowers the bar for the deny rules or the refusal criteria.
2 changes: 2 additions & 0 deletions products/stamphog/backend/logic/policy_defaults/policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ tiers:
overrides:
'size_gate.max_files':
ceiling: 50
'size_gate.max_lines':
ceiling: 1000
# Author-familiarity signal — judgment layer only, never a gate. Non-delegable (absent from
# `overrides`). STRONG = blame overlap >= min_blame_overlap_pct; MODERATE = both of its keys satisfied;
# else NONE. Thresholds are stamphog's backtested defaults.
Expand Down
2 changes: 2 additions & 0 deletions products/stamphog/packages/pr-approval-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Size ceiling (hard gate)
(limits derived from 90 days of denial outcomes: the friction cluster of
denied-yet-merged-unchanged PRs sits at 500-750 substantive lines, and past
~800 the merged-unchanged rate collapses, so escalation is genuinely right)
- A folder's AGENT_APPROVALS.md can raise either ceiling for its own files,
within the `overrides` contract in policy.yml (see .stamphog/README.md)
- Docs (.md/.txt/.rst anywhere; artifact-extension files under docs/),
snapshots (.snap/.ambr, __snapshots__/), images,
`.lock`-extension files (e.g. `yarn.lock`), tests (test dirs and
Expand Down
144 changes: 87 additions & 57 deletions products/stamphog/packages/pr-approval-agent/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,16 @@ def deny_pattern_defs(self) -> dict[str, dict[str, list[str]]]:

@dataclass(frozen=True)
class ScopeBudget:
"""One size-gate budget: a folder override's files, or the global pool.
"""One size-gate ceiling and the files it governs.

`path` is the granting AGENT_APPROVALS.md (repo-relative); None is the global
pool, which absorbs every file whose chain grants no valid max_files so
splitting files across pseudo-scopes can never inflate the allowance.
`path` is the nearest AGENT_APPROVALS.md granting that ceiling (repo-relative);
None is the global pool, which absorbs every file whose chain grants that
ceiling nowhere, so splitting files across pseudo-scopes can never inflate
the allowance.
"""

path: str | None
max_files: int
ceiling: int
files: tuple[str, ...]


Expand All @@ -199,18 +200,31 @@ class EffectivePolicy:
"""Per-PR resolved policy: per-scope size budgets plus advisory prose.

Mixed PRs get mixed leniency: every AGENT_APPROVALS.md at or above a changed
file governs it, and the file is budgeted by the nearest folder on that
chain with a valid max_files grant. Each scope's files must fit that scope's
own ceiling; files with no valid grant on their chain keep the global
ceiling. No file ever gets more leniency than its own chain grants.
max_lines stays a single global total; it is not delegable.
file governs it, and each ceiling resolves on its own - a file is budgeted by
the nearest folder on its chain granting that ceiling, and by the global pool
when no folder on the chain grants it. Lines and files therefore partition
independently, so a folder that raises one ceiling never opens a second
budget for the other. No file ever gets more leniency than its own chain
grants.
"""

max_lines: int
scopes: tuple[ScopeBudget, ...]
file_scopes: tuple[ScopeBudget, ...]
line_scopes: tuple[ScopeBudget, ...]
folder_prose: str | None = None
invalid_folder_files: tuple[str, ...] = ()

def governed_file_counts(self) -> tuple[tuple[str, int], ...]:
"""Changed files each granting AGENT_APPROVALS.md governs, path-sorted.

A folder can govern a file's line ceiling, its file ceiling, or both, so
each file counts once per folder.
"""
governed: dict[str, set[str]] = {}
for scope in (*self.file_scopes, *self.line_scopes):
if scope.path is not None:
governed.setdefault(scope.path, set()).update(scope.files)
return tuple((path, len(files)) for path, files in sorted(governed.items()))


class PolicyError(ValueError):
"""Raised when the global policy is malformed - fail closed at load time."""
Expand All @@ -229,9 +243,10 @@ class PolicyError(ValueError):
_DENY_SCOPES = {"any", "titles", "paths"}
_BREADTH_RULES = {"single-area", "not-cross-cutting"}

# The delegation contract's only delegable key. Everything else (deny, allow,
# tiers, size_gate.max_lines) is non-delegable by construction.
_DELEGABLE_KEYS = {"size_gate.max_files"}
# The delegation contract's delegable keys. Everything else (deny, allow, tiers)
# is non-delegable by construction.
_DELEGABLE_KEYS = {"size_gate.max_files", "size_gate.max_lines"}
_SIZE_GATE_DELEGATIONS = {"max_files": "size_gate.max_files", "max_lines": "size_gate.max_lines"}

# Invariant 7: self-governance deny must cover these path families so a future
# policy edit cannot silently drop stamphog's protection of its own files.
Expand Down Expand Up @@ -505,6 +520,7 @@ class _FolderOverride:
"""Result of parsing a folder AGENT_APPROVALS.md."""

max_files: int | None = None
max_lines: int | None = None
prose: str | None = None
invalid: bool = False

Expand Down Expand Up @@ -579,42 +595,45 @@ def _parse_folder_policy(path: Path, contract: dict[str, OverrideContract]) -> _
stamphog = frontmatter.get("stamphog")
if stamphog is None:
# Advisory-only file: no delegated override, prose still applies.
return _FolderOverride(max_files=None, prose=prose or None)
return _FolderOverride(prose=prose or None)
if not isinstance(stamphog, dict):
return _FolderOverride(invalid=True)

# Positive allow-list: the only delegated path is size_gate.max_files.
max_files = _read_delegated_max_files(stamphog, contract)
if max_files is None:
# Positive allow-list: the only delegated paths are the size_gate ceilings.
grants = _read_delegated_size_gate(stamphog, contract)
if grants is None:
return _FolderOverride(invalid=True)
return _FolderOverride(max_files=max_files, prose=prose or None)
return _FolderOverride(max_files=grants.get("max_files"), max_lines=grants.get("max_lines"), prose=prose or None)


def _read_delegated_max_files(stamphog: dict[str, Any], contract: dict[str, OverrideContract]) -> int | None:
"""Return the delegated max_files if valid and within ceiling, else None (invalid)."""
if "size_gate.max_files" not in contract:
return None
def _read_delegated_size_gate(stamphog: dict[str, Any], contract: dict[str, OverrideContract]) -> dict[str, int] | None:
if set(stamphog) - {"size_gate"}:
return None
size_gate = stamphog.get("size_gate")
if not isinstance(size_gate, dict) or set(size_gate) - {"max_files"}:
return None
value = size_gate.get("max_files")
if not isinstance(value, int) or isinstance(value, bool):
if not isinstance(size_gate, dict) or not size_gate:
return None
ceiling = contract["size_gate.max_files"].ceiling
if value < 1 or value > ceiling:
return None
return value
grants: dict[str, int] = {}
for key, value in size_gate.items():
contract_key = _SIZE_GATE_DELEGATIONS.get(key) if isinstance(key, str) else None
if contract_key is None or contract_key not in contract:
return None
if not isinstance(value, int) or isinstance(value, bool):
return None
if value < 1 or value > contract[contract_key].ceiling:
return None
grants[key] = value
return grants


def resolve(policy: Policy, changed_files: list[str]) -> EffectivePolicy:
"""Resolve the per-scope size budgets for a PR's changed files.

Every AGENT_APPROVALS.md at or above a changed file governs it. A file's size
budget comes from the nearest folder on its chain with a valid max_files
grant; files whose chain grants nothing (no folder file, prose-only, or only
invalid grants) pool into the global budget. Advisory prose accumulates from
Every AGENT_APPROVALS.md at or above a changed file governs it. Each ceiling
resolves on its own: a file counts against the nearest folder on its chain
granting that ceiling, and against the global pool when the chain grants it
nowhere (no folder file, prose-only, or only invalid grants). Lines and files
therefore partition independently, so a folder raising one ceiling leaves its
files in the global pool for the other. Advisory prose accumulates from
every valid folder file on the chain of at least one changed file, outermost
first so general guidance precedes specific. An invalid folder file is
treated as absent - it grants nothing and adds no prose, but its ancestors
Expand All @@ -630,29 +649,30 @@ def parsed_for(scope_dir: PurePosixPath) -> tuple[str, _FolderOverride]:
parse_cache[scope_dir] = (rel_path, _parse_folder_policy(root / rel_path, policy.overrides))
return parse_cache[scope_dir]

# Files sharing a granting AGENT_APPROVALS.md pool into one budget; the folder
# files touched by any chain feed the prose and invalid-file reporting.
grant_files: dict[str, list[str]] = {}
grant_max: dict[str, int] = {}
global_files: list[str] = []
# Files sharing a granting AGENT_APPROVALS.md pool into one budget, per
# ceiling; a None bucket key is the global pool. The folder files touched by
# any chain feed the prose and invalid-file reporting.
file_buckets: dict[str | None, list[str]] = {}
line_buckets: dict[str | None, list[str]] = {}
file_ceilings: dict[str, int] = {}
line_ceilings: dict[str, int] = {}
on_chain: dict[str, _FolderOverride] = {} # rel path -> parse, each file once
for file_path in changed_files:
grant: tuple[str, int] | None = None
file_scope: str | None = None
line_scope: str | None = None
for scope_dir in _scope_chain_for(file_path, root, dir_cache):
rel_path, parsed = parsed_for(scope_dir)
on_chain[rel_path] = parsed
if grant is None and not parsed.invalid and parsed.max_files is not None:
grant = (rel_path, parsed.max_files)
if grant is None:
global_files.append(file_path)
else:
grant_files.setdefault(grant[0], []).append(file_path)
grant_max[grant[0]] = grant[1]

override_scopes = [
ScopeBudget(path=rel_path, max_files=grant_max[rel_path], files=tuple(files))
for rel_path, files in sorted(grant_files.items())
]
if parsed.invalid:
continue
if file_scope is None and parsed.max_files is not None:
file_scope = rel_path
file_ceilings[rel_path] = parsed.max_files
if line_scope is None and parsed.max_lines is not None:
line_scope = rel_path
line_ceilings[rel_path] = parsed.max_lines
file_buckets.setdefault(file_scope, []).append(file_path)
line_buckets.setdefault(line_scope, []).append(file_path)

prose_parts: list[tuple[str, str]] = []
invalid_files: list[str] = []
Expand All @@ -672,10 +692,20 @@ def parsed_for(scope_dir: PurePosixPath) -> tuple[str, _FolderOverride]:
else:
folder_prose = None

scopes = (*override_scopes, ScopeBudget(path=None, max_files=policy.size_gate.max_files, files=tuple(global_files)))
return EffectivePolicy(
max_lines=policy.size_gate.max_lines,
scopes=scopes,
file_scopes=_scope_budgets(file_buckets, file_ceilings, policy.size_gate.max_files),
line_scopes=_scope_budgets(line_buckets, line_ceilings, policy.size_gate.max_lines),
folder_prose=folder_prose,
invalid_folder_files=tuple(invalid_files),
)


def _scope_budgets(
buckets: dict[str | None, list[str]], ceilings: dict[str, int], global_ceiling: int
) -> tuple[ScopeBudget, ...]:
"""Folder budgets sorted by path, then the global pool - present even when empty."""
overrides = [
ScopeBudget(path=path, ceiling=ceilings[path], files=tuple(buckets[path]))
for path in sorted(path for path in buckets if path is not None)
]
return (*overrides, ScopeBudget(path=None, ceiling=global_ceiling, files=tuple(buckets.get(None, ()))))
Loading
Loading