diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/agent.py b/agents/frontend-triage/hackbot_agents/frontend_triage/agent.py
index cc2d93e35b..dd70a9a850 100644
--- a/agents/frontend-triage/hackbot_agents/frontend_triage/agent.py
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/agent.py
@@ -16,6 +16,7 @@
import json
import re
import sys
+from collections.abc import Sequence
from pathlib import Path
from agent_tools import mozilla_vcs, searchfox
@@ -41,21 +42,30 @@
permalink_prefix,
resolve_index_revision,
)
+from mcp import ClientSession
+from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel
from searchfox import AsyncSearchfoxClient
+from . import areas as area_tools
+from .areas import AreaGuidanceContext
from .config import (
+ AREA_TOOLS,
+ AREAS,
BUGZILLA_READ_TOOLS,
ENABLED_ACTION_TYPES,
MOZILLA_VCS_TOOLS,
SEARCHFOX_TOOLS,
TRIAGE_SCOPE,
TRIAGE_SEVERITIES,
+ Area,
ScopedComponent,
+ areas_for,
)
-from .hooks import add_comment_hook, severity_block_hook
+from .hooks import add_comment_hook, area_guidance_hook, severity_block_hook
HERE = Path(__file__).resolve().parent
+AREAS_DIR = HERE / "rules" / "areas"
# The agent is asked to end its final message with a fenced ```json block
# carrying the structured plan. We parse the last such block so the result is
@@ -158,8 +168,8 @@ def render_scope(scope: tuple[ScopedComponent, ...] = TRIAGE_SCOPE) -> str:
"""Render `config.TRIAGE_SCOPE` as the prompt's component list, grouped by area.
Generated rather than written into the prompt so that the component list has one
- home. The per-area guidance under `Source repository` stays hand-authored: it is
- prose about a codebase, and only the enumeration is mechanical.
+ home. The `rules/areas/` guidance stays hand-authored: it is prose about a
+ codebase, and only the enumeration is mechanical.
Takes the registry as an argument so a test can assert the grouping against a fixed
input rather than against whatever the real scope happens to be today.
@@ -192,7 +202,78 @@ def render_scope(scope: tuple[ScopedComponent, ...] = TRIAGE_SCOPE) -> str:
)
-def load_system_prompt(rules_dir: Path, extra: str) -> str:
+async def fetch_product_component(
+ bugzilla_mcp_server: McpServerConfig, bug: int
+) -> tuple[str | None, str | None]:
+ """The bug's product and component, read through the Bugzilla broker.
+
+ The agent fetches the bug itself at step 1, but the prompt is built and frozen
+ before that, and the area guidance goes into it. Via the broker because the agent
+ container binds no Bugzilla credentials (see `compose.yml`).
+
+ Never raises. ``(None, None)`` makes `areas_for` send every area, which is the
+ prompt this replaced -- a broken lookup must not take down a workable run.
+ """
+ url = (
+ bugzilla_mcp_server.get("url")
+ if isinstance(bugzilla_mcp_server, dict)
+ else None
+ )
+ if not url:
+ return None, None
+
+ try:
+ async with streamablehttp_client(url) as (read, write, _):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ res = await session.call_tool(
+ "get_bugs",
+ # `id` is not optional: `get_bugs` diffs requested against
+ # returned ids to report inaccessible bugs, so leaving it out
+ # of `include_fields` makes the tool itself raise KeyError.
+ {"ids": [bug], "include_fields": "id,product,component"},
+ )
+ if res.isError:
+ raise RuntimeError(
+ "".join(getattr(c, "text", "") for c in res.content)
+ )
+ bugs = json.loads(res.content[0].text).get("bugs") or []
+ if not bugs:
+ return None, None
+ return bugs[0].get("product"), bugs[0].get("component")
+ except Exception as e: # - see docstring; every failure fails open
+ print(
+ f"[frontend_triage] component lookup failed ({type(e).__name__}: {e}); "
+ f"sending every area's guidance",
+ file=sys.stderr,
+ )
+ return None, None
+
+
+def render_area_index() -> str:
+ """One line per area: its name and the trees it covers.
+
+ Always in the prompt, even when one area's guidance is, so the agent can recognise
+ that it has localized into an area it does not have.
+ """
+ return "\n".join(f"- **{a.name}** — {', '.join(a.trees)}" for a in AREAS)
+
+
+def read_area_guidance(areas: Sequence[Area]) -> str:
+ """The `rules/areas/` files for ``areas``, concatenated for the prompt.
+
+ Headings drop two levels on the way in. The files are `# ` because
+ `load_area_guidance` serves them whole, but that H1 pasted between
+ `# Source repository` and `# Linking source files` reads as a new section.
+ """
+ bodies = []
+ for area in areas:
+ text = (AREAS_DIR / f"{area.slug}.md").read_text().strip()
+ bodies.append(re.sub(r"^(#{1,4}) ", r"##\1 ", text, flags=re.MULTILINE))
+ return "\n\n".join(bodies)
+
+
+def load_system_prompt(rules_dir: Path, extra: str, areas: Sequence[Area]) -> str:
tmpl = (HERE / "prompts" / "system.md").read_text()
return tmpl.format(
@@ -200,6 +281,8 @@ def load_system_prompt(rules_dir: Path, extra: str) -> str:
extra_instructions=extra or "(none)",
searchfox_links=SEARCHFOX_LINKS_PROMPT,
triaged_components=render_scope(),
+ area_index=render_area_index(),
+ area_guidance=read_area_guidance(areas),
)
@@ -510,13 +593,36 @@ async def run_frontend_triage(
)
actions_recorder.add_hook("bugzilla.add_comment", severity_block_hook)
+ # Which areas' guidance goes in the prompt. Falls back to every area when the bug's
+ # component is unknown or the lookup failed, which is what the prompt carried before
+ # this was split up -- see `areas_for`.
+ product, component = await fetch_product_component(bugzilla_mcp_server, bug)
+ areas = areas_for(product, component)
+ loaded_areas = {area.name for area in areas}
+ print(
+ f"[frontend_triage] {product} :: {component} -> "
+ f"{', '.join(sorted(loaded_areas))}",
+ file=sys.stderr,
+ )
+
+ # Registered before `permalink_hook`, which rewrites the placeholders this reads.
+ actions_recorder.add_hook("bugzilla.add_comment", area_guidance_hook(loaded_areas))
+
actions_recorder.add_hook(
"bugzilla.add_comment",
permalink_hook(permalink_prefix(searchfox_rev), source_repo.resolve()),
)
actions_recorder.add_hook("bugzilla.add_comment", feedback_tags_hook)
- system_prompt = load_system_prompt(rules_dir, instructions)
+ # Shares `loaded_areas` with the hook above, so an area the agent pulls mid-run
+ # stops the hook refusing a comment that cites it.
+ areas_server = build_sdk_server(
+ "areas",
+ AreaGuidanceContext(areas_dir=AREAS_DIR, loaded=loaded_areas),
+ area_tools.TOOLS,
+ )
+
+ system_prompt = load_system_prompt(rules_dir, instructions, areas)
options = ClaudeAgentOptions(
system_prompt=system_prompt,
@@ -524,6 +630,7 @@ async def run_frontend_triage(
"bugzilla": bugzilla_mcp_server,
"searchfox": searchfox_server,
"mozilla_vcs": vcs_server,
+ "areas": areas_server,
ACTIONS_SERVER_NAME: actions_server,
},
agents={
@@ -544,6 +651,7 @@ async def run_frontend_triage(
*BUGZILLA_READ_TOOLS,
*SEARCHFOX_TOOLS,
*MOZILLA_VCS_TOOLS,
+ *AREA_TOOLS,
*enabled_action_tools,
],
model=model,
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/areas.py b/agents/frontend-triage/hackbot_agents/frontend_triage/areas.py
new file mode 100644
index 0000000000..3ae27cb673
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/areas.py
@@ -0,0 +1,69 @@
+"""The ``load_area_guidance`` tool -- read a `rules/areas/` file mid-run.
+
+How the agent gets an area other than the one its component mapped to. A plain ``Read``
+would serve the agent equally well and `hooks.area_guidance_hook` not at all, which
+needs the load to be observable.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Annotated
+
+from agent_tools.registry import ToolError, tool, tools_in
+from pydantic import Field
+
+from .config import AREAS, AREAS_BY_NAME
+
+
+@dataclass
+class AreaGuidanceContext:
+ """Where the area files live, and which ones this run has loaded.
+
+ ``loaded`` is shared with the hook rather than copied, and starts as whatever the
+ prompt was built with.
+ """
+
+ areas_dir: Path
+ loaded: set[str]
+
+
+@tool
+async def load_area_guidance(
+ ctx: AreaGuidanceContext,
+ area: Annotated[
+ str,
+ Field(
+ description=(
+ "Area name, exactly as listed in the system prompt's area index -- "
+ "e.g. 'Windows installer', 'Site permissions'."
+ )
+ ),
+ ],
+) -> dict:
+ """Read the source-tree guidance for one area.
+
+ Call this when your investigation shows the bug's code is in an area whose guidance
+ is not already in your prompt. Recording a comment that cites files from an
+ unloaded area is refused.
+ """
+ entry = AREAS_BY_NAME.get(area) or next(
+ (a for a in AREAS if a.name.casefold() == area.casefold()), None
+ )
+ if entry is None:
+ raise ToolError(
+ f"no area named {area!r}",
+ payload={
+ "error": "unknown_area",
+ "requested": area,
+ "known_areas": [a.name for a in AREAS],
+ },
+ )
+
+ text = (ctx.areas_dir / f"{entry.slug}.md").read_text()
+ ctx.loaded.add(entry.name)
+ return {"area": entry.name, "guidance": text}
+
+
+TOOLS = tools_in(__name__)
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/config.py b/agents/frontend-triage/hackbot_agents/frontend_triage/config.py
index c70d1dce36..02e8abc89b 100644
--- a/agents/frontend-triage/hackbot_agents/frontend_triage/config.py
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/config.py
@@ -31,6 +31,14 @@
]
+# Per-area source-tree guidance (in-process MCP server "areas"). Only reached when the
+# agent localizes outside the area its component maps to; the usual case is already in
+# the prompt.
+AREA_TOOLS = [
+ "mcp__areas__load_area_guidance",
+]
+
+
# Recordable action types the agent may take, by dotted id. A comment is the only one:
# `bugzilla.update_bug` was here for `severity`, which is now a suggestion in the comment
# instead, leaving the tool with no caller.
@@ -48,20 +56,118 @@ class ScopedComponent(NamedTuple):
product: str
component: str
- # Which `Source repository` bullet in prompts/system.md describes this component's
- # code. `tests/test_plan.py` asserts every area named here has one, so a new area
- # cannot be added without the guidance that makes it triageable.
+ # Which `rules/areas/` file describes this component's code. `tests/test_plan.py`
+ # asserts every area named here has one, so a new area cannot be added without the
+ # guidance that makes it triageable.
area: str
# Required, because an entry without one would be a component getting unattended
# triage with nobody told -- which is what `channel_for` failing closed produces,
# and not something to be able to express by accident.
channel: str
+ # Areas sent alongside `area`, for components that routinely turn out to be
+ # somewhere else: a "stop sharing" report arrives under Sharing but is WebRTC,
+ # which site permissions owns. Both ship from the start rather than the agent
+ # having to notice mid-run.
+ related_areas: tuple[str, ...] = ()
@property
def key(self) -> str:
return f"{self.product} :: {self.component}"
+class Area(NamedTuple):
+ """One `rules/areas/` guidance file, and the trees whose code it describes."""
+
+ name: str
+ slug: str
+ # Where this area's code lives, for the prompt's index. Descriptive, so it may be
+ # broad and overlap another area.
+ trees: tuple[str, ...]
+ # Paths this area **exclusively** owns, for `area_for_path` and so for
+ # `hooks.area_guidance_hook`. Deliberately narrower than `trees`: enforcement needs
+ # "no other area could mean this file", and `browser/` fails that badly enough to
+ # refuse comments the guidance asked for -- `rules/areas/ip-protection.md` sends
+ # the agent to `browser/app/profile/firefox.js` for prefs. Empty for the desktop
+ # frontend, the general case, which owns nothing exclusively.
+ owns: tuple[str, ...] = ()
+
+
+# Every area, in the order they are listed to the model. `slug` is the filename under
+# `rules/areas/`.
+AREAS = (
+ # No `owns`: everything below sits inside these trees.
+ Area("Desktop frontend", "desktop-frontend", ("browser/", "toolkit/", "devtools/")),
+ Area(
+ "Site permissions",
+ "site-permissions",
+ (
+ "browser/modules/SitePermissions.sys.mjs",
+ "browser/modules/PermissionUI.sys.mjs",
+ "browser/actors/WebRTCParent.sys.mjs",
+ "extensions/permissions/",
+ ),
+ owns=(
+ "browser/modules/SitePermissions.sys.mjs",
+ "browser/modules/PermissionUI.sys.mjs",
+ "browser/actors/WebRTCParent.sys.mjs",
+ "extensions/permissions/",
+ ),
+ ),
+ Area(
+ "Sharing",
+ "sharing",
+ ("browser/components/sharing/", "widget/ (the per-OS half)"),
+ # Not `widget/`: that is the whole platform widget layer, and a bug in any
+ # other area citing a file there has nothing to do with sharing a URL out.
+ owns=(
+ "browser/components/sharing/",
+ "widget/nsIMacSharingService.idl",
+ "widget/cocoa/nsMacSharingService.mm",
+ ),
+ ),
+ Area(
+ "IP Protection",
+ "ip-protection",
+ ("browser/components/ipprotection/", "toolkit/components/ipprotection/"),
+ owns=("browser/components/ipprotection/", "toolkit/components/ipprotection/"),
+ ),
+ Area(
+ "Messaging System",
+ "messaging-system",
+ (
+ "browser/components/asrouter/",
+ "browser/components/aboutwelcome/",
+ "toolkit/components/messaging-system/",
+ ),
+ owns=(
+ "browser/components/asrouter/",
+ "browser/components/aboutwelcome/",
+ "toolkit/components/messaging-system/",
+ ),
+ ),
+ Area(
+ "Firefox for Android",
+ "firefox-for-android",
+ ("mobile/android/",),
+ owns=("mobile/android/",),
+ ),
+ Area(
+ "Application updater",
+ "application-updater",
+ ("toolkit/mozapps/update/",),
+ owns=("toolkit/mozapps/update/",),
+ ),
+ Area(
+ "Windows installer",
+ "windows-installer",
+ ("browser/installer/",),
+ owns=("browser/installer/",),
+ ),
+)
+
+AREAS_BY_NAME = {a.name: a for a in AREAS}
+
+
# The components that are sent here for triage, and the channel that owns each. The
# single source of truth for both: `SLACK_CHANNELS` below is derived from it, and
# `render_scope` in agent.py renders it into the system prompt, so adding a component is
@@ -89,7 +195,13 @@ def key(self) -> str:
ScopedComponent(
"Firefox", "Site Permissions", "Site permissions", "#privacy-team-automation"
),
- ScopedComponent("Firefox", "Sharing", "Sharing", "#content-sharing-automation"),
+ ScopedComponent(
+ "Firefox",
+ "Sharing",
+ "Sharing",
+ "#content-sharing-automation",
+ related_areas=("Site permissions",),
+ ),
ScopedComponent(
"Firefox",
"IP Protection",
@@ -124,6 +236,43 @@ def key(self) -> str:
# that `notify.py` keeps one flat mapping to look up.
SLACK_CHANNELS = {c.key: c.channel for c in TRIAGE_SCOPE}
+_SCOPE_BY_KEY = {c.key: c for c in TRIAGE_SCOPE}
+
+
+def areas_for(product: str | None, component: str | None) -> tuple[Area, ...]:
+ """The areas whose guidance belongs in the prompt for a bug in this component.
+
+ **Every** area for a component we do not triage, or one the caller could not
+ determine. `rules/scoping.md` puts an unlisted component in scope, so guessing one
+ area would leave those runs with less than they have today; failing open costs only
+ the prompt size it already had.
+ """
+ entry = _SCOPE_BY_KEY.get(
+ f"{(product or '').strip()} :: {(component or '').strip()}"
+ )
+ if entry is None:
+ return AREAS
+ return tuple(AREAS_BY_NAME[name] for name in (entry.area, *entry.related_areas))
+
+
+def area_for_path(path: str) -> Area | None:
+ """The area that exclusively owns ``path``, or None if none does.
+
+ None is the common and correct answer -- it covers both a file outside the triaged
+ areas (`gfx/`) and ordinary desktop chrome. Read it as "no guidance is specific to
+ this file", never as "guidance is missing".
+
+ Longest match wins, so `browser/installer/...` is the installer even though the
+ desktop frontend describes `browser/`.
+ """
+ best: tuple[int, Area] | None = None
+ for area in AREAS:
+ for owned in area.owns:
+ if path.startswith(owned) and (best is None or len(owned) > best[0]):
+ best = (len(owned), area)
+ return best[1] if best else None
+
+
# Bugzilla's `bug_severity` legal values are `--`, `blocker`, `S1`, `critical`,
# `S2`, `major`, `normal`, `S3`, `minor`, `S4`, `trivial`, `N/A`, `enhancement`
# (https://bugzilla.mozilla.org/rest/field/bug/bug_severity). Narrowed to the four
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/hooks.py b/agents/frontend-triage/hackbot_agents/frontend_triage/hooks.py
index 50fbcc86b8..522cfc27e2 100644
--- a/agents/frontend-triage/hackbot_agents/frontend_triage/hooks.py
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/hooks.py
@@ -24,6 +24,8 @@
from agent_tools.registry import ToolError
from hackbot_runtime.actions import ActionHook, ActionsRecorder
+from .config import area_for_path
+
# A line the model writes to declare its severity. Anchored to the line start so an
# ordinary mention -- quoting a reporter, or arguing why something is not S1 -- does
# not count as a second declaration.
@@ -46,6 +48,54 @@ def severity_block_hook(action: dict) -> None:
)
+# Source paths reach the comment as the Searchfox placeholder plus a repo-relative
+# path (see `SEARCHFOX_LINKS_PROMPT`), which is why this hook has to run before
+# `permalink_hook` rewrites them into URLs.
+_CITED_PATH = re.compile(r"\{\{\s*searchfox\.permalink\s*\}\}/?(?P[^)\s#]+)")
+
+
+def cited_paths(text: str) -> list[str]:
+ """Every repo-relative source path the comment links to."""
+ return [m.group("path") for m in _CITED_PATH.finditer(text)]
+
+
+def area_guidance_hook(loaded_areas: set[str]) -> ActionHook:
+ """Refuse a comment citing code from an area whose guidance was never loaded.
+
+ The prompt carries the area the bug's component maps to. When the investigation
+ lands somewhere else, nothing but this makes the agent go and read that area.
+
+ ``loaded_areas`` is shared with the ``areas`` MCP server, which adds to it as the
+ agent loads files, so the retry after a refusal succeeds.
+
+ A path in no area passes: `gfx/` has nothing to load, so refusing it would fail the
+ run over something the agent cannot satisfy.
+ """
+
+ def hook(action: dict) -> None:
+ text = (action.get("params") or {}).get("text")
+ if not isinstance(text, str):
+ return
+
+ missing: dict[str, str] = {}
+ for path in cited_paths(text):
+ area = area_for_path(path)
+ if area is not None and area.name not in loaded_areas:
+ missing.setdefault(area.name, path)
+
+ if missing:
+ named = ", ".join(
+ f"{area} (e.g. {path})" for area, path in sorted(missing.items())
+ )
+ raise ToolError(
+ f"your comment cites code in {named}, whose guidance you have not "
+ f"read. Call `load_area_guidance` for each, then revise the comment "
+ f"against what it says -- your localization may be wrong."
+ )
+
+ return hook
+
+
def _check_no_comment_yet(recorder: ActionsRecorder) -> None:
# The rules ask for a single comment, but nothing else caps the count, and the
# agent reads every comment on the bug as untrusted input. A run told to write
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/prompts/system.md b/agents/frontend-triage/hackbot_agents/frontend_triage/prompts/system.md
index 6047512b31..7824ce0029 100644
--- a/agents/frontend-triage/hackbot_agents/frontend_triage/prompts/system.md
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/prompts/system.md
@@ -36,34 +36,15 @@ Use **only** these tools for accessing Bugzilla, nothing else.
Your working directory is the Firefox source repository — the whole tree, desktop and Android in one checkout. You have Read, Grep, Glob, and Bash (read-only — do not modify files) to inspect it. Use this to localize the bug: find the modules, markup, styling, and prefs (often under `modules/libpref/init/all.js`) that govern the behaviour, and any existing tests that cover the area.
-Where to look, and what you will find there, depends on the bug's component:
-
-- **Desktop frontend** — `browser/`, `toolkit/`, and `devtools/`. JS/JSM modules (`.js`, `.mjs`, `.sys.mjs`), CSS, and XUL/HTML.
-- **Site permissions** — desktop JS, but split across the prompt, the state, and the store, so start by working out which of the three the bug is in. `browser/modules/SitePermissions.sys.mjs` holds the permission state the rest of the frontend reads and writes, including the defaults, the scopes (`SCOPE_PERSISTENT`, `SCOPE_SESSION`, `SCOPE_TEMPORARY`), and the `ALLOW`/`BLOCK`/`PROMPT` states. `browser/modules/PermissionUI.sys.mjs` builds the doorhanger prompts, one subclass per permission type. `browser/actors/WebRTCParent.sys.mjs` handles camera, microphone, and screen sharing, which do **not** go through the generic prompt path and carry their own sharing indicator. The management UI is `browser/components/preferences/dialogs/permissions.js` and `sitePermissions.js`. The backing store is `nsIPermissionManager`, implemented in C++ at `extensions/permissions/PermissionManager.cpp` — that is outside the frontend directories, so "the permission did not stick", "it came back after a restart", and wrong-expiry bugs are localized there and are **not** out of scope for being non-JS.
-- **Sharing** — sending the current page to another app, and the one area here whose code reaches outside `browser/`, `toolkit/` and `devtools/`. `browser/modules/SharingUtils.sys.mjs` is the frontend: it populates the share menu, gates on `BrowserUtils.getShareableURL` (which is why an unshareable scheme silently yields no menu item), and then hands off to the platform. `browser/components/contentsharing/` is the newer piece — `ContentSharingUtils.sys.mjs`, the remotely-delivered config validated against `contentsharing.schema.json`, `content/`, and its own `metrics.yaml`. **The platform half is in `widget/`**, which the desktop-frontend bullet above does not cover: `widget/nsIMacSharingService.idl` with `widget/cocoa/nsMacSharingService.mm` (Objective-C++ — the macOS share sheet, `getSharingProviders`, `openSharingPreferences`), and `widget/nsIWindowsUIUtils.idl`'s `shareUrl` for Windows. So "the Share menu is empty", "the wrong apps are listed", and "Share does nothing" are usually localized in `widget/`, per-OS, and are **not** out of scope for being C++ rather than JS. Note which OS the bug is about before reading either.
- - **Two unrelated things are called "sharing" in this tree.** This component is sharing a URL _out_ to another app. Screen, camera and microphone sharing — the sharing indicator, "stop sharing" button, and per-tab sharing state — is WebRTC, lives in `browser/actors/WebRTCParent.sys.mjs`, and belongs to site permissions. A grep for `sharing` returns both, so check which one the report is actually about; a bug about an indicator or a "stop sharing" control is almost certainly the WebRTC one.
-- **IP Protection** — the built-in VPN, desktop JS in two trees, and which tree matters more than which file. `browser/components/ipprotection/` is the UI and the per-window glue: `IPProtection.sys.mjs` (`EveryWindow` and `CustomizableUI` registration), `IPProtectionPanel.sys.mjs` (panel lifecycle and the only sanctioned way to change what the panel shows, `setState`), `IPProtectionToolbarButton.sys.mjs`, `IPProtectionInfobarManager.sys.mjs`, `IPProtectionAlertManager.sys.mjs`, and one-concern `IPP*Helper.sys.mjs` files for onboarding, opt-out, and usage. The panel's own markup is Lit components under `content/*.mjs` (`ipprotection-content.mjs`, `ipprotection-status-card.mjs`, `ipprotection-locations.mjs`, `ipprotection-message-bar.mjs`), with shared values — thresholds, URLs, country-to-flag maps — in `content/ipprotection-constants.mjs`. `toolkit/components/ipprotection/` is the platform-agnostic service layer: `IPProtectionService.sys.mjs`, `IPPProxyManager.sys.mjs`, `IPPChannelFilter.sys.mjs` (which traffic is proxied), `IPPNetworkErrorObserver.sys.mjs`, `IPProtectionServerlist.sys.mjs`, `IPPAuthProvider.sys.mjs`, `IPPExceptionsManager.sys.mjs` (per-site exclusions), `IPPNimbusHelper.sys.mjs`.
- - **State lives in the service, not the panel**, so a bug whose symptom is in the panel usually is not. There are **two** state machines and both have a `READY`: `IPProtectionStates` in `IPProtectionService.sys.mjs` is entitlement and sign-in (`UNINITIALIZED`, `UNAVAILABLE`, `UNAUTHENTICATED`, `READY`) and fires `IPProtectionService:StateChanged`; `IPPProxyStates` in `IPPProxyManager.sys.mjs` is the connection (`NOT_READY`, `READY`, `ACTIVATING`, `ACTIVE`, `ERROR`, `PAUSED`) and fires `IPPProxyManager:StateChanged`. Say which one you mean. "It showed connected when it was not" and "it came back on after I turned it off" are proxy-state bugs in `toolkit/`; "the panel offered it to a user who is not entitled" is a service-state bug. The panel only reacts, through `setState`, and content components emit `IPProtection:*` events upward rather than acting.
- - `toolkit/components/ipprotection/docs/` has `StateMachine.rst`, `Preferences.rst`, `Constants.rst` and `Components.rst` — in-tree prose documentation, which none of the other areas here has. **Read it before reasoning about a state transition**; it is faster and more reliable than reconstructing the machine from the source.
- - A `browser/` → `toolkit/` split is in progress, so both trees can hold a plausible-looking copy of the same concern and the shallow local checkout may be behind. Prefer `search_identifier` / `find_definition`, which see the indexed revision, before citing a path.
- - Prefs are `browser.ipProtection.*`, registered in `browser/app/profile/firefox.js` — **not** `modules/libpref/init/all.js`. Strings are `browser/locales/en-US/browser/ipProtection.ftl`, and Glean metrics are in a `metrics.yaml` in each of the two directories.
-- **Messaging System** — the in-product messaging surfaces: about:welcome, feature callouts, Spotlight modal dialogs, and Infobars. Desktop JS/JSM, CSS and XUL/HTML across three trees: `browser/components/asrouter/` (the router that decides which message shows, and the templates it shows them in), `browser/components/aboutwelcome/` (the onboarding and first-run flow), and `toolkit/components/messaging-system/` (the platform-agnostic layer, including the **JSON Schemas** message definitions are validated against). The router is shared by every surface, so work out which surface the reporter was on before reading any of the three — a bug in one surface is usually not in the router.
- - **A message is data, not code.** Message definitions are delivered remotely and matched to a user by targeting expressions, so "I saw the wrong message", "I saw it twice" and "I never saw it" are usually a message-definition, targeting or frequency-cap problem rather than a defect in this tree. Say which of the two you think it is, and if it is the message rather than the code, say what would confirm that instead of planning a change against a definition you cannot see. A rendering or interaction bug in the surface itself is the ordinary case and localizes normally.
-- **Firefox for Android** — `mobile/android/`, with the Fenix app under `mobile/android/fenix/app/src/main/java/org/mozilla/fenix/` and the reusable components under `mobile/android/android-components/`. This is **Kotlin**, and it is structured as Fragment / Store / Middleware / View rather than as chrome markup plus a script: a `…Fragment.kt` owns the screen, a `…FragmentStore.kt` holds its state and actions, a `…View.kt` or a Compose function renders it, and a `…Middleware.kt` performs side effects. Layouts are Android XML under `mobile/android/fenix/app/src/main/res/layout/`, strings under `res/values/strings.xml`. Fenix is mid-migration to Jetpack Compose, so a screen may have both a `…View.kt` and a `…Composable.kt` and only one of them is live — check which the Fragment actually builds before planning against either.
- - **Android toolbar** — there are **two** toolbars, and a generation of the widget under each. The browser toolbar is `…/fenix/components/toolbar/` (`BrowserToolbarComposable.kt`, `BrowserToolbarMiddleware.kt`, `BrowserNavigationBar.kt`, `ToolbarPosition.kt` for top-versus-bottom, `BottomToolbarContainerView.kt`, `ToolbarsIntegration.kt`); the homepage has its own at `…/fenix/home/toolbar/` (`HomeToolbarComposable.kt`, `FenixHomeToolbar.kt`, `BrowserSimpleToolbar.kt`). So work out which surface the reporter was on first: a `Homepage` bug can localize into a toolbar file and a `Toolbar` bug into the homepage. Underneath both, android-components has the newer Compose widget at `mobile/android/android-components/components/compose/browser-toolbar/` and the older View-based one at `components/browser/toolbar/`, with `components/concept/toolbar/` holding the interface and `components/feature/toolbar/` the session wiring. Confirm which one Fenix builds before citing it — a fix planned against the retired implementation reads correct and changes nothing.
- - **Android homepage** — one screen assembled from one package per section, so "which section" comes before "which file". `…/fenix/home/HomeFragment.kt` owns the screen, the Compose UI is under `home/ui/` (`Homepage.kt`, `HomepageHeader.kt`, `SearchBar.kt`, `WallpaperBackground.kt`, `Wordmark.kt`), state is `home/store/HomepageState.kt`, side effects are `home/middleware/`, and the older controller/interactor pair is `home/sessioncontrol/`. Each section is its own subpackage: `topsites/`, `recenttabs/`, `recentsyncedtabs/`, `recentvisits/`, `pocket/`, `bookmarks/`, `collections/`, `setup/`, `sports/`, `mars/`, `logo/`, `privatebrowsing/`. A bug about the top-sites row or the stories feed is localized there, not in `Homepage.kt`. Note also that `Firefox for Android` has separate components for several of these sections — `Top Sites`, `Stories`, `Collections`, `Bookmarks`, `Menu`, `Search` — so the same code can be reached from more than one component, and `Stories` is `home/pocket/` in the tree because nothing was renamed. Triage the bug under the component it was filed in; do not retitle or re-scope it to match.
-- **Application updater** — `toolkit/mozapps/update/`. `.sys.mjs` modules (`AppUpdater.sys.mjs`, `UpdateService.sys.mjs`, `BackgroundUpdate.sys.mjs`), the XPCOM interfaces in `nsIUpdateService.idl`, and the C++ updater binary under `toolkit/mozapps/update/updater/`. Update behaviour is heavily driven by prefs under `app.update.*` and by the state written to the update directory, so read `common/` for the shared constants and status codes.
-- **Windows installer** — `browser/installer/windows/nsis/`. This is **NSIS**: `installer.nsi` (the full installer), `stub.nsi` (the small downloader stub), `uninstaller.nsi`, `maintenanceservice_installer.nsi`, and the `.nsh` include files that hold most of the logic. Localized strings live in the `.nsi`/`.properties` files alongside. The packaging manifests are `browser/installer/package-manifest.in` and `browser/installer/allowed-dupes.mn`, and the MSI and MSIX wrappers are in the sibling `msi/` and `msix/` directories. There is no JS here at all. Note which installer the bug is about: the stub and the full installer are separate programs with separate code.
-
-**Always look for an existing test that exercises the affected area**, and record what you find in the `relevant_tests` field — it is the downstream executor's verification anchor. Where to look depends on the component:
-
-- Desktop: browser-chrome mochitests usually live in a component's `tests/browser/` directory; also check `tests/`/`test/` and xpcshell tests.
-- Sharing: browser-chrome under `browser/components/contentsharing/tests/browser/`, which has a `ContentSharingMockServer.sys.mjs` for the remote config — use it rather than stubbing the fetch yourself. Schema fixtures are xpcshell under `tests/unit/` (`validContentSharing.*.json` / `invalidContentSharing.*.json`), so a config-parsing bug has a very cheap regression test. The `widget/` half is effectively uncovered: there is no automated test for the macOS share sheet or the Windows share dialog, so for a platform-side bug say the area is untested rather than leaving the reader wondering.
-- IP Protection: browser-chrome under `browser/components/ipprotection/tests/browser/`, which is where most of the coverage is, with shared setup in its `head.js` (`openPanel`, `closePanel`, and the panel-state helpers) — a new test almost always belongs there rather than in a bespoke setup. Also `browser/components/ipprotection/tests/xpcshell/` and, for the service layer, `toolkit/components/ipprotection/tests/xpcshell/`. Name the one matching the layer you localized to.
-- Site permissions: the prompts are covered by browser-chrome under `browser/base/content/test/permissions/`, `SitePermissions.sys.mjs` itself by `browser/modules/test/browser/`, and the store by xpcshell under `extensions/permissions/test/`. Name the one that matches the layer you localized to, not whichever you found first.
-- Android: Kotlin unit tests under `mobile/android/fenix/app/src/test/java/org/mozilla/fenix/`, and instrumented UI tests under `app/src/androidTest/`. The test tree mirrors the source packages, so name the mirror of the package you localized to — `…/test/java/org/mozilla/fenix/components/toolbar/` for the browser toolbar, `…/fenix/home/topsites/` for a top-sites bug — rather than the screen-level `HomeFragmentTest.kt`. A Compose surface may be covered only by an `androidTest` UI test; say so rather than reporting no coverage.
-- Updater: `toolkit/mozapps/update/tests/` — xpcshell under `unit_aus_update/`, `unit_background_update/`, and `unit_update_binary/`, browser-chrome under `browser/`, plus `marionette/` and C++ `gtest/`.
-- Installer: coverage is thin and specific. `browser/installer/windows/nsis/test/xpcshell/test_stub_installer.js` drives `test_stub.nsi` and covers the **stub** installer only; nothing exercises `installer.nsi` or the uninstaller. So for most Installer bugs an empty `relevant_tests` is the correct answer — say that the area is uncovered rather than leaving the reader to wonder whether you looked.
+Where to look, and what you will find there, depends on the bug's component. Every area and the trees it covers:
+
+{area_index}
+
+Guidance for this bug's area follows. If your investigation shows the code is in a different area, call `load_area_guidance` with that area's name before you write the fix plan — a comment citing files from an area you have not loaded will be refused. A file in no area at all (`gfx/`, say) needs no load; there is nothing to fetch.
+
+{area_guidance}
+
+**Always look for an existing test that exercises the affected area**, and record what you find in the `relevant_tests` field — it is the downstream executor's verification anchor. Where to look is in the area guidance above.
If you searched and there is genuinely no covering test, say so (empty `relevant_tests`).
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/application-updater.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/application-updater.md
new file mode 100644
index 0000000000..34187f50db
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/application-updater.md
@@ -0,0 +1,7 @@
+# Application updater
+
+`toolkit/mozapps/update/`. `.sys.mjs` modules (`AppUpdater.sys.mjs`, `UpdateService.sys.mjs`, `BackgroundUpdate.sys.mjs`), the XPCOM interfaces in `nsIUpdateService.idl`, and the C++ updater binary under `toolkit/mozapps/update/updater/`. Update behaviour is heavily driven by prefs under `app.update.*` and by the state written to the update directory, so read `common/` for the shared constants and status codes.
+
+## Tests
+
+`toolkit/mozapps/update/tests/` — xpcshell under `unit_aus_update/`, `unit_background_update/`, and `unit_update_binary/`, browser-chrome under `browser/`, plus `marionette/` and C++ `gtest/`.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/desktop-frontend.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/desktop-frontend.md
new file mode 100644
index 0000000000..f4cd46738d
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/desktop-frontend.md
@@ -0,0 +1,7 @@
+# Desktop frontend
+
+`browser/`, `toolkit/`, and `devtools/`. JS/JSM modules (`.js`, `.mjs`, `.sys.mjs`), CSS, and XUL/HTML.
+
+## Tests
+
+browser-chrome mochitests usually live in a component's `tests/browser/` directory; also check `tests/`/`test/` and xpcshell tests.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/firefox-for-android.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/firefox-for-android.md
new file mode 100644
index 0000000000..8542d9195f
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/firefox-for-android.md
@@ -0,0 +1,11 @@
+# Firefox for Android
+
+`mobile/android/`, with the Fenix app under `mobile/android/fenix/app/src/main/java/org/mozilla/fenix/` and the reusable components under `mobile/android/android-components/`. This is **Kotlin**, and it is structured as Fragment / Store / Middleware / View rather than as chrome markup plus a script: a `…Fragment.kt` owns the screen, a `…FragmentStore.kt` holds its state and actions, a `…View.kt` or a Compose function renders it, and a `…Middleware.kt` performs side effects. Layouts are Android XML under `mobile/android/fenix/app/src/main/res/layout/`, strings under `res/values/strings.xml`. Fenix is mid-migration to Jetpack Compose, so a screen may have both a `…View.kt` and a `…Composable.kt` and only one of them is live — check which the Fragment actually builds before planning against either.
+
+**Android toolbar** — there are **two** toolbars, and a generation of the widget under each. The browser toolbar is `…/fenix/components/toolbar/` (`BrowserToolbarComposable.kt`, `BrowserToolbarMiddleware.kt`, `BrowserNavigationBar.kt`, `ToolbarPosition.kt` for top-versus-bottom, `BottomToolbarContainerView.kt`, `ToolbarsIntegration.kt`); the homepage has its own at `…/fenix/home/toolbar/` (`HomeToolbarComposable.kt`, `FenixHomeToolbar.kt`, `BrowserSimpleToolbar.kt`). So work out which surface the reporter was on first: a `Homepage` bug can localize into a toolbar file and a `Toolbar` bug into the homepage. Underneath both, android-components has the newer Compose widget at `mobile/android/android-components/components/compose/browser-toolbar/` and the older View-based one at `components/browser/toolbar/`, with `components/concept/toolbar/` holding the interface and `components/feature/toolbar/` the session wiring. Confirm which one Fenix builds before citing it — a fix planned against the retired implementation reads correct and changes nothing.
+
+**Android homepage** — one screen assembled from one package per section, so "which section" comes before "which file". `…/fenix/home/HomeFragment.kt` owns the screen, the Compose UI is under `home/ui/` (`Homepage.kt`, `HomepageHeader.kt`, `SearchBar.kt`, `WallpaperBackground.kt`, `Wordmark.kt`), state is `home/store/HomepageState.kt`, side effects are `home/middleware/`, and the older controller/interactor pair is `home/sessioncontrol/`. Each section is its own subpackage: `topsites/`, `recenttabs/`, `recentsyncedtabs/`, `recentvisits/`, `pocket/`, `bookmarks/`, `collections/`, `setup/`, `sports/`, `mars/`, `logo/`, `privatebrowsing/`. A bug about the top-sites row or the stories feed is localized there, not in `Homepage.kt`. Note also that `Firefox for Android` has separate components for several of these sections — `Top Sites`, `Stories`, `Collections`, `Bookmarks`, `Menu`, `Search` — so the same code can be reached from more than one component, and `Stories` is `home/pocket/` in the tree because nothing was renamed. Triage the bug under the component it was filed in; do not retitle or re-scope it to match.
+
+## Tests
+
+Kotlin unit tests under `mobile/android/fenix/app/src/test/java/org/mozilla/fenix/`, and instrumented UI tests under `app/src/androidTest/`. The test tree mirrors the source packages, so name the mirror of the package you localized to — `…/test/java/org/mozilla/fenix/components/toolbar/` for the browser toolbar, `…/fenix/home/topsites/` for a top-sites bug — rather than the screen-level `HomeFragmentTest.kt`. A Compose surface may be covered only by an `androidTest` UI test; say so rather than reporting no coverage.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/ip-protection.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/ip-protection.md
new file mode 100644
index 0000000000..5c5ef8659f
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/ip-protection.md
@@ -0,0 +1,15 @@
+# IP Protection
+
+the built-in VPN, desktop JS in two trees, and which tree matters more than which file. `browser/components/ipprotection/` is the UI and the per-window glue: `IPProtection.sys.mjs` (`EveryWindow` and `CustomizableUI` registration), `IPProtectionPanel.sys.mjs` (panel lifecycle and the only sanctioned way to change what the panel shows, `setState`), `IPProtectionToolbarButton.sys.mjs`, `IPProtectionInfobarManager.sys.mjs`, `IPProtectionAlertManager.sys.mjs`, and one-concern `IPP*Helper.sys.mjs` files for onboarding, opt-out, and usage. The panel's own markup is Lit components under `content/*.mjs` (`ipprotection-content.mjs`, `ipprotection-status-card.mjs`, `ipprotection-locations.mjs`, `ipprotection-message-bar.mjs`), with shared values — thresholds, URLs, country-to-flag maps — in `content/ipprotection-constants.mjs`. `toolkit/components/ipprotection/` is the platform-agnostic service layer: `IPProtectionService.sys.mjs`, `IPPProxyManager.sys.mjs`, `IPPChannelFilter.sys.mjs` (which traffic is proxied), `IPPNetworkErrorObserver.sys.mjs`, `IPProtectionServerlist.sys.mjs`, `IPPAuthProvider.sys.mjs`, `IPPExceptionsManager.sys.mjs` (per-site exclusions), `IPPNimbusHelper.sys.mjs`.
+
+**State lives in the service, not the panel**, so a bug whose symptom is in the panel usually is not. There are **two** state machines and both have a `READY`: `IPProtectionStates` in `IPProtectionService.sys.mjs` is entitlement and sign-in (`UNINITIALIZED`, `UNAVAILABLE`, `UNAUTHENTICATED`, `READY`) and fires `IPProtectionService:StateChanged`; `IPPProxyStates` in `IPPProxyManager.sys.mjs` is the connection (`NOT_READY`, `READY`, `ACTIVATING`, `ACTIVE`, `ERROR`, `PAUSED`) and fires `IPPProxyManager:StateChanged`. Say which one you mean. "It showed connected when it was not" and "it came back on after I turned it off" are proxy-state bugs in `toolkit/`; "the panel offered it to a user who is not entitled" is a service-state bug. The panel only reacts, through `setState`, and content components emit `IPProtection:*` events upward rather than acting.
+
+`toolkit/components/ipprotection/docs/` has `StateMachine.rst`, `Preferences.rst`, `Constants.rst` and `Components.rst` — in-tree prose documentation, which none of the other triage areas has. **Read it before reasoning about a state transition**; it is faster and more reliable than reconstructing the machine from the source.
+
+A `browser/` → `toolkit/` split is in progress, so both trees can hold a plausible-looking copy of the same concern and the shallow local checkout may be behind. Prefer `search_identifier` / `find_definition`, which see the indexed revision, before citing a path.
+
+Prefs are `browser.ipProtection.*`, registered in `browser/app/profile/firefox.js` — **not** `modules/libpref/init/all.js`. Strings are `browser/locales/en-US/browser/ipProtection.ftl`, and Glean metrics are in a `metrics.yaml` in each of the two directories.
+
+## Tests
+
+browser-chrome under `browser/components/ipprotection/tests/browser/`, which is where most of the coverage is, with shared setup in its `head.js` (`openPanel`, `closePanel`, and the panel-state helpers) — a new test almost always belongs there rather than in a bespoke setup. Also `browser/components/ipprotection/tests/xpcshell/` and, for the service layer, `toolkit/components/ipprotection/tests/xpcshell/`. Name the one matching the layer you localized to.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/messaging-system.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/messaging-system.md
new file mode 100644
index 0000000000..a327283bd3
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/messaging-system.md
@@ -0,0 +1,5 @@
+# Messaging System
+
+the in-product messaging surfaces: about:welcome, feature callouts, Spotlight modal dialogs, and Infobars. Desktop JS/JSM, CSS and XUL/HTML across three trees: `browser/components/asrouter/` (the router that decides which message shows, and the templates it shows them in), `browser/components/aboutwelcome/` (the onboarding and first-run flow), and `toolkit/components/messaging-system/` (the platform-agnostic layer, including the **JSON Schemas** message definitions are validated against). The router is shared by every surface, so work out which surface the reporter was on before reading any of the three — a bug in one surface is usually not in the router.
+
+**A message is data, not code.** Message definitions are delivered remotely and matched to a user by targeting expressions, so "I saw the wrong message", "I saw it twice" and "I never saw it" are usually a message-definition, targeting or frequency-cap problem rather than a defect in this tree. Say which of the two you think it is, and if it is the message rather than the code, say what would confirm that instead of planning a change against a definition you cannot see. A rendering or interaction bug in the surface itself is the ordinary case and localizes normally.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/sharing.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/sharing.md
new file mode 100644
index 0000000000..caeebf856e
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/sharing.md
@@ -0,0 +1,9 @@
+# Sharing
+
+sending the current page to another app, and the one area here whose code reaches outside `browser/`, `toolkit/` and `devtools/`. `browser/components/sharing/` holds all of it. `SharingUtils.sys.mjs` there is the frontend: it populates the share menu, gates on `BrowserUtils.getShareableURL` (which is why an unshareable scheme silently yields no menu item), and then hands off to the platform. Alongside it, `ContentSharingUtils.sys.mjs` is the newer piece — the remotely-delivered config validated against `contentsharing.schema.json`, `content/`, and its own `metrics.yaml`. **The platform half is in `widget/`**, which the desktop frontend trees (`browser/`, `toolkit/`, `devtools/`) do not cover: `widget/nsIMacSharingService.idl` with `widget/cocoa/nsMacSharingService.mm` (Objective-C++ — the macOS share sheet, `getSharingProviders`, `openSharingPreferences`), and `widget/nsIWindowsUIUtils.idl`'s `shareUrl` for Windows. So "the Share menu is empty", "the wrong apps are listed", and "Share does nothing" are usually localized in `widget/`, per-OS, and are **not** out of scope for being C++ rather than JS. Note which OS the bug is about before reading either.
+
+**Two unrelated things are called "sharing" in this tree.** This component is sharing a URL _out_ to another app. Screen, camera and microphone sharing — the sharing indicator, "stop sharing" button, and per-tab sharing state — is WebRTC, lives in `browser/actors/WebRTCParent.sys.mjs`, and belongs to site permissions. A grep for `sharing` returns both, so check which one the report is actually about; a bug about an indicator or a "stop sharing" control is almost certainly the WebRTC one.
+
+## Tests
+
+browser-chrome under `browser/components/sharing/tests/browser/`, which has a `ContentSharingMockServer.sys.mjs` for the remote config — use it rather than stubbing the fetch yourself. Schema fixtures are xpcshell under `browser/components/sharing/tests/unit/` (`validContentSharing.*.json` / `invalidContentSharing.*.json`), so a config-parsing bug has a very cheap regression test. The `widget/` half is effectively uncovered: there is no automated test for the macOS share sheet or the Windows share dialog, so for a platform-side bug say the area is untested rather than leaving the reader wondering.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/site-permissions.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/site-permissions.md
new file mode 100644
index 0000000000..7e49c57e5b
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/site-permissions.md
@@ -0,0 +1,7 @@
+# Site permissions
+
+desktop JS, but split across the prompt, the state, and the store, so start by working out which of the three the bug is in. `browser/modules/SitePermissions.sys.mjs` holds the permission state the rest of the frontend reads and writes, including the defaults, the scopes (`SCOPE_PERSISTENT`, `SCOPE_SESSION`, `SCOPE_TEMPORARY`), and the `ALLOW`/`BLOCK`/`PROMPT` states. `browser/modules/PermissionUI.sys.mjs` builds the doorhanger prompts, one subclass per permission type. `browser/actors/WebRTCParent.sys.mjs` handles camera, microphone, and screen sharing, which do **not** go through the generic prompt path and carry their own sharing indicator. The management UI is `browser/components/preferences/dialogs/permissions.js` and `sitePermissions.js`. The backing store is `nsIPermissionManager`, implemented in C++ at `extensions/permissions/PermissionManager.cpp` — that is outside the frontend directories, so "the permission did not stick", "it came back after a restart", and wrong-expiry bugs are localized there and are **not** out of scope for being non-JS.
+
+## Tests
+
+the prompts are covered by browser-chrome under `browser/base/content/test/permissions/`, `SitePermissions.sys.mjs` itself by `browser/modules/test/browser/`, and the store by xpcshell under `extensions/permissions/test/`. Name the one that matches the layer you localized to, not whichever you found first.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/windows-installer.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/windows-installer.md
new file mode 100644
index 0000000000..842d833de3
--- /dev/null
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/areas/windows-installer.md
@@ -0,0 +1,7 @@
+# Windows installer
+
+`browser/installer/windows/nsis/`. This is **NSIS**: `installer.nsi` (the full installer), `stub.nsi` (the small downloader stub), `uninstaller.nsi`, `maintenanceservice_installer.nsi`, and the `.nsh` include files that hold most of the logic. Localized strings live in the `.nsi`/`.properties` files alongside. The packaging manifests are `browser/installer/package-manifest.in` and `browser/installer/allowed-dupes.mn`, and the MSI and MSIX wrappers are in the sibling `msi/` and `msix/` directories. There is no JS here at all. Note which installer the bug is about: the stub and the full installer are separate programs with separate code.
+
+## Tests
+
+coverage is thin and specific. `browser/installer/windows/nsis/test/xpcshell/test_stub_installer.js` drives `test_stub.nsi` and covers the **stub** installer only; nothing exercises `installer.nsi` or the uninstaller. So for most Installer bugs an empty `relevant_tests` is the correct answer — say that the area is uncovered rather than leaving the reader to wonder whether you looked.
diff --git a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/duplicate-detection.md b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/duplicate-detection.md
index 12f90f75d0..b73e5343f3 100644
--- a/agents/frontend-triage/hackbot_agents/frontend_triage/rules/duplicate-detection.md
+++ b/agents/frontend-triage/hackbot_agents/frontend_triage/rules/duplicate-detection.md
@@ -1,12 +1,9 @@
# Duplicate detection
-These rules apply to **every** bug: the `duplicate_hunter` subagent runs before
-localization and asks one question — is this already filed?
-
-The verdict is a **suggestion only**. Naming a duplicate never stops the triage: the fix
-plan is written either way, `actionable` stays `true`, and confidence is unaffected. A
-human decides whether the bugs are really the same and marks them. The agent cannot mark
-one — it has no tool that changes a Bugzilla field.
+These rules apply to **every** bug. They are the judgment behind the verdict the
+`duplicate_hunter` returns — what makes two reports the same defect, and when to keep
+quiet. Spawning it, reporting the verdict and recording it are all in the system prompt,
+and are not repeated here.
## What counts as the same defect
@@ -39,14 +36,7 @@ does:
- **IP Protection.** Whether the proxy state is _displayed_ wrong or _actually_ wrong.
Those are different defects with different severities and should not be merged.
-## Reporting
-
-- **Candidate found** — the triage comment opens with `**Possible duplicate:** ` and
- nothing else about it. No hedging, no explanation; the reader opens the bug and judges.
-- **Nothing found** — say nothing. The comment opens with the analysis as usual. Absence is
- not reported.
-- **Uncertain** — prefer reporting nothing. A wrong duplicate sends someone to the wrong
- bug and costs more trust than a missed one costs time.
+## The bar for naming one
-Record the same verdict in the `duplicate_assessment` structured output either way, so a
-run that found nothing is distinguishable from one that never looked.
+Prefer reporting nothing when uncertain. A wrong duplicate sends someone to the wrong bug
+and costs more trust than a missed one costs time.
diff --git a/agents/frontend-triage/tests/test_hooks.py b/agents/frontend-triage/tests/test_hooks.py
index 56554feb4c..cb8849b918 100644
--- a/agents/frontend-triage/tests/test_hooks.py
+++ b/agents/frontend-triage/tests/test_hooks.py
@@ -8,7 +8,11 @@
import pytest
from agent_tools.registry import ToolError
from hackbot_agents.frontend_triage.config import ENABLED_ACTION_TYPES
-from hackbot_agents.frontend_triage.hooks import add_comment_hook, severity_block_hook
+from hackbot_agents.frontend_triage.hooks import (
+ add_comment_hook,
+ area_guidance_hook,
+ severity_block_hook,
+)
from hackbot_runtime.actions import ActionsRecorder
from hackbot_runtime.actions.claude_sdk import actions_to_tool_names
@@ -94,3 +98,48 @@ def test_a_comment_may_declare_its_severity_only_once():
with pytest.raises(ToolError):
severity_block_hook({"params": {"text": plan + block + block}})
+
+
+def _cite(path: str) -> str:
+ """A comment citing ``path`` the way the prompt asks for it."""
+ return f"The fault is in [{path}]({{{{searchfox.permalink}}}}/{path})."
+
+
+def _area_hook(*loaded: str):
+ hook = area_guidance_hook(set(loaded))
+ return lambda text: hook({"params": {"bug_id": BUG, "text": text}})
+
+
+def test_a_comment_citing_an_unloaded_area_is_refused():
+ # A New Tab Page bug that turns out to be the installer. The agent acts on this
+ # in-run, so the message has to name the area and the tool.
+ with pytest.raises(ToolError) as e:
+ _area_hook("Desktop frontend")(
+ _cite("browser/installer/windows/nsis/installer.nsi")
+ )
+ assert "Windows installer" in str(e.value)
+ assert "load_area_guidance" in str(e.value)
+
+
+def test_a_comment_citing_a_loaded_area_passes():
+ _area_hook("Desktop frontend")(_cite("browser/components/tabbrowser/tabgroup.js"))
+
+
+def test_an_area_loaded_mid_run_passes():
+ # `load_area_guidance` adds to the same set the hook reads, so the retry after a
+ # refusal succeeds. If this ever decoupled, the agent would be stuck in a loop it
+ # cannot exit and the run would burn its turns.
+ loaded = {"Desktop frontend"}
+ hook = area_guidance_hook(loaded)
+ body = _cite("browser/installer/windows/nsis/installer.nsi")
+ with pytest.raises(ToolError):
+ hook({"params": {"bug_id": BUG, "text": body}})
+ loaded.add("Windows installer")
+ hook({"params": {"bug_id": BUG, "text": body}})
+
+
+def test_a_comment_citing_no_known_area_passes():
+ # A Graphics bug has no file to load, and it triages fine today off the source tree
+ # and Searchfox. Refusing here would fail the run over something the agent cannot
+ # satisfy -- it would retry forever against guidance that does not exist.
+ _area_hook("Desktop frontend")(_cite("gfx/thebes/gfxPlatform.cpp"))
diff --git a/agents/frontend-triage/tests/test_plan.py b/agents/frontend-triage/tests/test_plan.py
index 191c369722..074941a8eb 100644
--- a/agents/frontend-triage/tests/test_plan.py
+++ b/agents/frontend-triage/tests/test_plan.py
@@ -4,9 +4,11 @@
with nobody in between, so it is covered as closely as the hooks are.
"""
+import re
from pathlib import Path
from hackbot_agents.frontend_triage.agent import (
+ AREAS_DIR,
load_system_prompt,
may_apply_unattended,
parse_bug_id,
@@ -17,7 +19,13 @@
parse_severity_assessment,
render_scope,
)
-from hackbot_agents.frontend_triage.config import TRIAGE_SCOPE, ScopedComponent
+from hackbot_agents.frontend_triage.config import (
+ AREAS,
+ TRIAGE_SCOPE,
+ ScopedComponent,
+ area_for_path,
+ areas_for,
+)
def _block(body: str) -> str:
@@ -28,7 +36,7 @@ def test_the_system_prompt_renders():
# system.md goes through str.format, so a literal brace in it must be doubled or
# startup raises KeyError and the run never begins. The structured-output block is
# where that happens.
- prompt = load_system_prompt(Path("rules"), "")
+ prompt = load_system_prompt(Path("rules"), "", areas_for("Firefox", "New Tab Page"))
assert '"severity_assessment": {' in prompt
assert "{rules_dir}" not in prompt
assert "{triaged_components}" not in prompt
@@ -64,16 +72,61 @@ def test_the_scope_says_it_is_neither_a_limit_nor_a_vocabulary():
assert "verbatim" in rendered
-def test_every_area_has_prompt_guidance():
+def test_every_area_has_a_guidance_file():
# The registry is what makes a component triaged; this is what makes it triageable.
- # `Source repository` carries the per-area code layout, and an area with no bullet
- # there means the agent is pointed at a component with no idea where its code lives
- # -- which is how a bug gets read as out of scope and skipped. So a new area costs
- # two files, visibly, rather than one file plus a prompt nobody remembered.
- prompt = load_system_prompt(Path("rules"), "")
- source_section = prompt.split("# Source repository", 1)[1]
- for area in {entry.area for entry in TRIAGE_SCOPE}:
- assert f"**{area}**" in source_section, area
+ # An area whose file is missing points the agent at a component with no idea where
+ # its code lives -- which is how a bug gets read as out of scope and skipped. So a
+ # new area costs two files, visibly, rather than one file plus a prompt nobody
+ # remembered.
+ for area in AREAS:
+ assert (AREAS_DIR / f"{area.slug}.md").is_file(), area.name
+
+
+def test_every_registry_area_resolves():
+ # `area` and `related_areas` are strings, so a typo in either is only caught here.
+ # `areas_for` would raise KeyError mid-run, after the bug was already fetched.
+ names = {a.name for a in AREAS}
+ for entry in TRIAGE_SCOPE:
+ assert entry.area in names, entry.key
+ for related in entry.related_areas:
+ assert related in names, f"{entry.key} -> {related}"
+
+
+def test_an_unknown_component_gets_every_area():
+ # `rules/scoping.md` puts an unlisted component in scope, so guessing one area for
+ # it would leave the run with less than it has today. Failing open costs the old
+ # prompt size and nothing else.
+ assert areas_for("Firefox", "Graphics") == AREAS
+ assert areas_for(None, None) == AREAS
+
+
+def test_only_the_matching_area_reaches_the_prompt():
+ # The point of the split. Everything else stays reachable via the index and
+ # `load_area_guidance`, but its text is not paid for on every run.
+ prompt = load_system_prompt(Path("rules"), "", areas_for("Firefox", "New Tab Page"))
+ assert "NSIS" not in prompt
+ assert "IPProtectionPanel.sys.mjs" not in prompt
+ # ...while the index still names every area, so a mislocalized bug is recognisable.
+ for area in AREAS:
+ assert f"**{area.name}**" in prompt, area.name
+
+
+def test_an_owned_subtree_resolves_even_though_a_broader_area_describes_it():
+ # The desktop frontend's index entry covers `browser/`, but the installer and IP
+ # Protection sit inside it and own their own subtrees. Ownership has to follow the
+ # specific claim, or the hook never fires for the areas whose guidance matters most.
+ assert area_for_path("browser/installer/windows/nsis/stub.nsi").name == (
+ "Windows installer"
+ )
+ assert area_for_path(
+ "browser/components/ipprotection/IPProtection.sys.mjs"
+ ).name == ("IP Protection")
+
+
+def test_a_path_in_no_area_belongs_to_no_area():
+ # Load-bearing for `area_guidance_hook`: None means "no guidance exists", not
+ # "guidance is missing", and must not be treated as something the agent can fetch.
+ assert area_for_path("gfx/thebes/gfxPlatform.cpp") is None
def test_confidence_is_normalized():
@@ -274,3 +327,40 @@ def test_a_duplicate_verdict_does_not_change_what_reaches_a_bug():
assert may_apply_unattended(base)
assert may_apply_unattended(found)
assert may_apply_unattended(none_found)
+
+
+# Paths written as `some/dir/File.ext` in an area's guidance prose.
+_GUIDANCE_PATH = re.compile(r"`([a-z][a-z0-9_./-]*/[A-Za-z0-9_./-]+)`")
+
+
+def test_guidance_never_names_a_path_its_own_component_cannot_cite():
+ # The invariant that keeps `area_guidance_hook` honest, and the one that caught
+ # `browser/` being listed as owned: an area told the agent where the prefs and
+ # strings were, and citing them then had the comment refused. Every path a
+ # component's own guidance names has to survive the hook for that component --
+ # including across `related_areas`, which is what makes Sharing's reference to
+ # WebRTCParent legal.
+ for entry in TRIAGE_SCOPE:
+ areas = areas_for(entry.product, entry.component)
+ loaded = {a.name for a in areas}
+ for area in areas:
+ text = (AREAS_DIR / f"{area.slug}.md").read_text()
+ for match in _GUIDANCE_PATH.finditer(text):
+ owner = area_for_path(match.group(1))
+ assert owner is None or owner.name in loaded, (
+ f"{entry.key}: guidance names {match.group(1)}, "
+ f"owned by {owner.name if owner else None}"
+ )
+
+
+def test_ordinary_desktop_chrome_is_owned_by_nobody():
+ # `browser/` and `toolkit/` describe the desktop frontend usefully in the index but
+ # contain almost every other area, so treating them as owned refuses comments for
+ # the ordinary reason that a Firefox bug touches a Firefox file.
+ for path in (
+ "browser/base/content/browser.js",
+ "browser/app/profile/firefox.js",
+ "toolkit/content/widgets/panel-list.js",
+ "widget/cocoa/nsCocoaWindow.mm",
+ ):
+ assert area_for_path(path) is None, path