Skip to content
Closed
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
52 changes: 52 additions & 0 deletions Discovery/adr_discovery/enumerator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""M2 -- where should we look.

Answers that question once, for the whole pipeline. Nothing downstream may
decide it for itself, and nothing here knows what any particular tool is:
`enumerate_candidates` is run with an empty catalog in its own test set,
and must produce the same candidates it produces with a full one.
"""

from __future__ import annotations

from ..contracts.records import Candidate
from .roots import homes
from .sources.appstate import from_app_state
from .sources.binaries import from_binaries
from .sources.execjournal import from_exec_journal
from .sources.modelstores import from_model_stores
from .sources.network import from_network
from .sources.registries import from_applications, from_kernel, from_packages
from .sweep import sweep

__all__ = ["enumerate_candidates"]


def enumerate_candidates(gate, include_dependency_caches: bool = False) -> tuple[Candidate, ...]:
"""Registries first, then the sweep.

The order is the optimisation: most of the search is over before the
walk starts, and every registry hit arrives with provenance attached.
"""
found: list[Candidate] = []

# Half one -- ask what already has the answer.
found.extend(from_packages(gate))
found.extend(from_applications(gate))
kernel = from_kernel(gate)
found.extend(kernel)
found.extend(from_network(gate, kernel))
found.extend(from_exec_journal(gate))
found.extend(from_app_state(gate, homes(gate)))
found.extend(from_binaries(gate, homes(gate)))
found.extend(from_model_stores(gate, homes(gate)))

registry_entries = gate.budget.entries_used

# Half two -- sweep only what no registry indexes.
found.extend(sweep(gate, include_dependency_caches))

gate.ledger.probe(
"enumerator", "ran",
f"{len(found)} candidates; {registry_entries} entries before the sweep",
)
return tuple(found)
155 changes: 155 additions & 0 deletions Discovery/adr_discovery/enumerator/markers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""The marker set, as data.

Traversal is keyed on markers rather than on remembered paths, which is
what lets a repository in /opt/checkouts be found by the same rule that
finds one in ~/Projects.

Nothing here names a tool. A marker says *where an agent works*; deciding
what the agent is belongs to M4, and M2 must stay passable with an empty
catalog (U2-03).
"""

from __future__ import annotations

#: Directory names that mark a surface worth reading.
DIR_MARKERS: frozenset[str] = frozenset(
{
".git", ".claude", ".cursor", ".windsurf", ".aider", ".continue",
".codeium", ".gemini", ".goose", ".opencode", ".zed",
"agents", "skills", "commands", "prompts", "output-styles", "plugins",
".github", ".devcontainer", ".vscode",
}
)

#: File names that mark a surface worth reading.
FILE_MARKERS: frozenset[str] = frozenset(
{
".mcp.json", ".claude.json", "mcp.json", "settings.json", "settings.local.json",
"config.toml", "config.yaml", "mcp_settings.json", "mcp_config.json",
"cline_mcp_settings.json", "managed-settings.json", "managed-mcp.json",
"claude_desktop_config.json", "opencode.json",
}
)

#: Workflow files are read by suffix rather than by name -- nobody agrees
#: on what a workflow is called, only on where it lives.
WORKFLOW_DIR = "/.github/workflows/"
WORKFLOW_SUFFIXES = (".yml", ".yaml")

#: Instruction filenames are programmable-surface records. Their contents are
#: never collected; only path, scope and host-facing name leave the endpoint.
INSTRUCTION_MARKERS: frozenset[str] = frozenset(
{
"CLAUDE.md", "AGENTS.md", "GEMINI.md", "AGENT.md",
".cursorrules", ".windsurfrules", "copilot-instructions.md",
}
)

LOCATOR_ONLY: frozenset[str] = frozenset({".cursorrules", ".windsurfrules"})

#: State directories a host application keeps per user.
STATE_ROOTS: tuple[str, ...] = (
"~/.claude", "~/.codex", "~/.cursor", "~/.aider", "~/.continue",
"~/.gemini", "~/.config/goose", "~/.config/opencode", "~/.ollama",
"~/Library/Application Support/Claude",
"~/Library/Application Support/Code/User",
"~/.config/Code/User",
"~/.vscode/extensions", "~/.vscode-server/extensions",
)

#: Config files loaded directly by known agent hosts. These are enumerated
#: independently of the breadth sweep so a dependency cache cannot hide them.
CONFIG_FILE_TEMPLATES: tuple[str, ...] = (
"~/.claude.json",
"~/.config/claude-desktop/claude_desktop_config.json",
"~/.cursor/mcp.json",
"~/.codeium/windsurf/mcp_config.json",
"~/.config/Code/User/mcp.json",
"~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"~/.config/zed/settings.json",
"~/.config/JetBrains/options/mcp.json",
"~/.config/opencode/opencode.json",
"~/.codex/config.toml",
"~/.config/goose/config.yaml",
"~/.bashrc",
"~/.zshrc",
"/etc/claude-code/managed-settings.json",
"/etc/adr/managed-mcp.json",
)

#: Browser profile parents. Every profile, not just the default -- a large
#: share of real shadow AI lives on a second profile.
BROWSER_PROFILE_ROOTS: tuple[str, ...] = (
"~/Library/Application Support/Google/Chrome",
"~/Library/Application Support/BraveSoftware/Brave-Browser",
"~/Library/Application Support/Microsoft Edge",
"~/Library/Application Support/Arc/User Data",
"~/.config/google-chrome",
"~/.config/chromium",
"~/.config/microsoft-edge",
)

FIREFOX_PROFILE_ROOTS: tuple[str, ...] = (
"~/Library/Application Support/Firefox/Profiles",
"~/.mozilla/firefox",
)

EDITOR_EXTENSION_ROOTS: tuple[str, ...] = (
"~/.vscode/extensions", "~/.vscode-server/extensions",
"~/.cursor/extensions", "~/.windsurf/extensions",
"~/.trae/extensions", "~/.kilo/extensions",
)

#: Hosts that answer for a model provider. Landscape data, not identity:
#: a connection here says *something on this machine talks to a model*,
#: which is a candidate. What it is remains M4's question.
MODEL_PROVIDER_SUFFIXES: tuple[str, ...] = (
"api.anthropic.com", "api.openai.com", "openai.azure.com",
"generativelanguage.googleapis.com", "aiplatform.googleapis.com",
"bedrock-runtime.amazonaws.com", "api.mistral.ai", "api.cohere.ai",
"api.groq.com", "api.together.xyz", "api.deepseek.com",
"api.x.ai", "openrouter.ai", "huggingface.co",
)

#: Bundles and portable executables carry their own runtime, so nothing
#: else on disk reveals them.
BUNDLE_SUFFIXES: tuple[str, ...] = (".AppImage", ".app", ".exe")

#: Ports a local model runtime answers on.
LOCAL_MODEL_PORTS: frozenset[int] = frozenset({11434, 1234, 8080, 8000, 5000, 7860})


def is_model_provider(host: str) -> bool:
h = host.lower().rstrip(".")
return any(h == s or h.endswith("." + s) for s in MODEL_PROVIDER_SUFFIXES)


def is_loose_executable(entry, name: str) -> bool:
"""An executable nothing else on disk accounts for.

Extensionless is the test that keeps this from matching every script in
every repository: real CLI tools ship as `claude`, not `claude.sh`.
"""
if entry.is_dir:
return name.endswith(BUNDLE_SUFFIXES)
if name.endswith(BUNDLE_SUFFIXES):
return True
return entry.is_exec and "." not in name


def marker_kind(name: str, path: str = "") -> str | None:
if WORKFLOW_DIR in path and path.endswith(WORKFLOW_SUFFIXES):
return "marker_file"
if name in DIR_MARKERS:
return "marker_dir"
if name in FILE_MARKERS:
return "marker_file"
if name in INSTRUCTION_MARKERS:
return "instruction_file"
if name in (".bashrc", ".zshrc"):
return "shell_profile"
if name == "manifest.json" and "/.mcpb/" in path:
return "marker_file"
if name in LOCATOR_ONLY:
return "locator"
return None
96 changes: 96 additions & 0 deletions Discovery/adr_discovery/enumerator/roots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Priority roots -- one definition.

There were five copies of this tuple, in five probe files, none of which
reported that it had a boundary. Roots now *order* the sweep so the common
case stays fast; they no longer decide what exists.
"""

from __future__ import annotations

from ..contracts.records import Priority

#: (template, priority). `~` is expanded per discovered home, not per the
#: user running the scan -- the owner of an asset is a person, never whoever
#: happened to launch the collector.
ROOT_TEMPLATES: tuple[tuple[str, Priority], ...] = (
("~", Priority.HOME),
("~/Projects", Priority.CODE_ROOT),
("~/src", Priority.CODE_ROOT),
("~/code", Priority.CODE_ROOT),
("~/work", Priority.CODE_ROOT),
("~/dev", Priority.CODE_ROOT),
("~/git", Priority.CODE_ROOT),
("~/repos", Priority.CODE_ROOT),
("/opt", Priority.SYSTEM),
("/srv", Priority.SYSTEM),
("/usr/local", Priority.SYSTEM),
("/workspace", Priority.SYSTEM),
("/Users", Priority.BREADTH),
("/home", Priority.BREADTH),
)

#: Scope is policy, not a constant. Whether a dependency cache is in scope is
#: a real question with a defensible answer either way, so it lives here with
#: a stated default rather than in a tuple nobody can see.
DEPENDENCY_CACHES: tuple[str, ...] = (
"node_modules", ".venv", "venv", "site-packages", "go/pkg/mod",
".cargo/registry", "vendor", ".gradle", ".m2",
)

SKIP_ALWAYS: tuple[str, ...] = (
".git/objects", ".Trash", "Library/Caches", ".cache", "__pycache__",
"/.npm/", "/.local/share/pipx/", "/.cargo/registry/", "/.gradle/", "/.m2/",
)


def homes(gate) -> tuple[str, ...]:
"""Every home on the machine, not just the caller's.

Where homes live is a platform question and is answered by M1's
provider, not by a tuple here -- which is the same rule that removed
the five copies of PROJECT_ROOTS.
"""
found: list[str] = []
for base in gate.providers.home_roots():
listing = gate.list_dir(base)
if not listing.ok:
continue
for entry in listing.value:
if entry.is_dir and not entry.path.rsplit("/", 1)[-1].startswith("."):
found.append(entry.path)
if not found:
home = gate.env.get("HOME")
if home:
found.append(home)
return tuple(found)


def ordered_roots(gate) -> tuple[tuple[str, Priority], ...]:
"""Roots in sweep order: home first, then code roots, then breadth.

Order is asserted by U2-06, because a budget exhausted late must still
have covered the likely places.
"""
out: list[tuple[str, Priority]] = []
seen: set[str] = set()
for home in homes(gate):
for template, priority in ROOT_TEMPLATES:
if not template.startswith("~"):
continue
path = home + template[1:]
if path not in seen:
seen.add(path)
out.append((path, priority))
for template, priority in ROOT_TEMPLATES:
if template.startswith("~") or template in seen:
continue
seen.add(template)
out.append((template, priority))
out.sort(key=lambda pair: pair[1])
return tuple(out)


def in_scope(path: str, include_dependency_caches: bool = False) -> bool:
if any(seg in path for seg in SKIP_ALWAYS):
return False
return include_dependency_caches or not any(seg in path for seg in DEPENDENCY_CACHES)
Empty file.
Loading