diff --git a/clawmetry/local_store.py b/clawmetry/local_store.py index 3a7ab0afa0..45beaa3512 100644 --- a/clawmetry/local_store.py +++ b/clawmetry/local_store.py @@ -46,7 +46,7 @@ import time from collections import deque from contextlib import contextmanager -from datetime import datetime +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Iterable, Iterator @@ -4346,6 +4346,89 @@ def query_recent_loop_signals( return out + # ── Repo activity (repo AI-readiness pairing) ───────────────────────── + def query_repo_activity( + self, + *, + since_days: int = 30, + limit: int = 5000, + ) -> "list[dict[str, Any]]": + """Sessions that ran in a known directory, with the loop signal (if + any) the detector wrote for each. + + Powers the "before you blame the agent, look at what you handed it" + pairing: ``clawmetry.repo_readiness`` groups these rows by git root + so a repo's readiness grade sits next to the stuck rate that repo + actually produced. + + One row per (session, signal); a session with two distinct signals + yields two rows, and a session with none yields one row with NULL + signal columns — so the caller can count sessions and incidents from + the same result without a second query. Sessions with no recorded + ``cwd`` are excluded: they cannot be attributed to a repo, and + guessing one would fabricate the correlation this feature exists to + show. + + ``since_days <= 0`` disables the window. ``limit`` is clamped to + ``[1, 50000]``. + """ + try: + days = int(since_days) + except (TypeError, ValueError): + days = 30 + try: + lim = int(limit) + except (TypeError, ValueError): + lim = 5000 + lim = max(1, min(50000, lim)) + + clauses = ["s.cwd IS NOT NULL", "s.cwd <> ''"] + params: "list[Any]" = [] + if days > 0: + # ``sessions.last_active_at`` is a VARCHAR holding an ISO-8601 + # UTC timestamp, so the window is a STRING comparison against an + # ISO cutoff — the same shape ``query_sessions_table``'s ``since`` + # filter uses. Casting to TIMESTAMP here is what a first draft + # does and it is a binder error, because the column is not one. + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + clauses.append("s.last_active_at >= ?") + params.append(cutoff.isoformat()) + where = "WHERE " + " AND ".join(clauses) + sql = f""" + SELECT s.session_id, s.agent_type, s.cwd, s.git_branch, + s.last_active_at, s.cost_usd, + l.signature, l.repeat_count, l.severity, l.details + FROM sessions s + LEFT JOIN loop_signals l ON l.session_id = s.session_id + {where} + ORDER BY s.last_active_at DESC, s.session_id + LIMIT ? + """ + params.append(lim) + cols = ["session_id", "agent_type", "cwd", "git_branch", + "last_active_at", "cost_usd", "signature", "repeat_count", + "severity", "details"] + out: "list[dict[str, Any]]" = [] + for r in self._fetch(sql, params): + d = dict(zip(cols, r)) + v = d.get("last_active_at") + if hasattr(v, "isoformat"): + d["last_active_at"] = v.isoformat() + raw = d.get("details") + if raw is not None: + try: + raw = _ccr.maybe_decompress(raw) + text = (raw.decode("utf-8") + if isinstance(raw, (bytes, bytearray)) else raw) + try: + d["details"] = json.loads(text) + except (ValueError, TypeError): + d["details"] = text + except UnicodeDecodeError: + d["details"] = None + out.append(d) + return out + # ── Guard baselines ─────────────────────────────────────────────────── def record_guard_observation(self, session_id: str, cohort: str, runtime: str = "", agent_id: str = "", diff --git a/clawmetry/repo_readiness.py b/clawmetry/repo_readiness.py new file mode 100644 index 0000000000..9ee5a3865b --- /dev/null +++ b/clawmetry/repo_readiness.py @@ -0,0 +1,990 @@ +"""clawmetry/repo_readiness.py — how legible is this repo to an agent? + +Before you blame the agent, look at what you handed it. A repo with no +instruction file, no discoverable test command and no lint gate produces +stuck loops, and ClawMetry already has the detector data to show that +correlation on the same screen. + +This module scores a repository on the things an agent actually needs to +find its way around: + + instruction file · instruction loaded · test command · build command + lint gate · CI config · agent assets (skills / commands / sub-agents) + +Design rules this module is bound by +------------------------------------ +**ADR-004 posture grading** (the rule the Security tab's posture registry +already follows, recorded on the Local Observability Service blueprint): + +* ``fail`` — ONLY a filesystem fact, where "is it honoured?" does not + arise: a file exists or it does not, a literal byte is in a file or it + is not. Every failing check here traces to code in this module that + opened the thing it grades. +* ``warn`` — present but partial, or an inherited default we did not + measure. An inherited default counts as **unmeasured, not ready**. +* ``unknown`` — **weight 0**. A thing we cannot read at all is reported + as an explicit unknown that cannot move the grade in either direction. + A file we lack permission to read, or a runtime that does not report + what it loaded, lands here. Never a penalty. + +**No network calls.** Every input is a filesystem fact in a directory the +caller already named. Nothing here opens a socket, and nothing here runs a +subprocess: we never execute the build we are grading. "Does the build +succeed?" is a question a read-only observer cannot answer without +changing the machine, so the graded check is "is a build command +discoverable", and the execution axis is reported as an honest unknown +unless a detector already observed it. + +**Derived, not hand-maintained.** The per-runtime instruction / skills +file names come from ``runtime_memory.project_relative_roots()`` — the same +declarations the Memory and Skills browsers read — so a new runtime flows +in automatically instead of drifting a second copy. + +Everything is pure: :func:`score_repo` takes a path and an optional bundle +of already-measured signals, and returns a JSON-ready envelope. It never +raises. +""" +from __future__ import annotations + +import json +import logging +import os +import re +from datetime import datetime +from typing import Any, Iterable, Optional + +log = logging.getLogger("clawmetry.repo_readiness") + +#: Largest config file we will read while probing. Real Makefiles and +#: package.json files are far below this; anything larger is a data file +#: that got named like a config, and reading it is not worth the stall. +_MAX_PROBE_BYTES = 512 * 1024 + +#: How deep to look for a git root when the caller hands us a subdirectory. +_MAX_GIT_WALK = 24 + +# ── status vocabulary ────────────────────────────────────────────────────── +PASS = "pass" +WARN = "warn" +FAIL = "fail" +UNKNOWN = "unknown" + + +def _check(cid: str, label: str, status: str, detail: str, + remediation: Optional[str], weight: int, *, + evidence: Optional[str] = None, + severity: str = "medium") -> dict: + """One graded check. + + ``evidence`` names the file this module actually opened to reach the + verdict, so a reader can tell a measured result from a shipped + constant. An ``unknown`` check is forced to weight 0 here rather than + at every call site, so no future check can accidentally penalise the + operator for something we could not read. + """ + return { + "id": cid, + "label": label, + "status": status, + "detail": detail, + "remediation": remediation, + "severity": severity, + "weight": 0 if status == UNKNOWN else int(weight), + "evidence": evidence, + } + + +# ═══════════════════════════════════════════════════════════════════════════ +# Filesystem probes. Every one of these opens (or stats) a real path and +# reports what it found there. None of them run anything. +# ═══════════════════════════════════════════════════════════════════════════ + +class _Unreadable(Exception): + """A path exists but we could not read it — grade UNKNOWN, weight 0.""" + + +def _probe_key(path: str): + """Identity of a path for de-duplication. + + ``Makefile`` and ``makefile`` are the SAME file on macOS and Windows, + so probing both would report one Makefile twice. Case-folding the + string is not enough (``realpath`` preserves the case you asked for, + and ``normcase`` is the identity on POSIX), so identity comes from the + inode when we can stat it. On a case-sensitive filesystem the two + names are genuinely different files with different inodes and both are + still reported, which is correct. + """ + try: + st = os.stat(path) + return (st.st_dev, st.st_ino) + except OSError: + return os.path.normcase(os.path.abspath(path)) + + +def _read_text(path: str) -> Optional[str]: + """Read a small text file. ``None`` when it does not exist. + + Raises :class:`_Unreadable` when the path is there but unreadable, so + the caller can grade an explicit zero-weight unknown instead of + reporting "absent" for something that may well be present. + """ + try: + if not os.path.isfile(path): + return None + except OSError as e: + raise _Unreadable(str(e)) + try: + with open(path, "rb") as fh: + raw = fh.read(_MAX_PROBE_BYTES) + except OSError as e: + raise _Unreadable(str(e)) + return raw.decode("utf-8", errors="replace") + + +def _exists(path: str) -> bool: + try: + return os.path.exists(path) + except OSError: + return False + + +def _isdir_nonempty(path: str) -> bool: + try: + if not os.path.isdir(path): + return False + with os.scandir(path) as it: + for _ in it: + return True + return False + except OSError: + return False + + +def _make_targets(text: str) -> set: + """Target names declared in a Makefile. + + Reads the literal bytes: a line starting at column 0 with + ``name:`` (not ``.PHONY``, not a variable assignment) declares a + target. Recipe lines are tab-indented and never match. + """ + out = set() + for line in text.splitlines(): + if not line or line[0] in (" ", "\t", "#"): + continue + m = re.match(r"^([A-Za-z0-9_.\-/ ]+?)\s*:(?!=)", line) + if not m: + continue + for name in m.group(1).split(): + if name.startswith("."): + continue + out.add(name) + return out + + +def _package_scripts(text: str) -> Optional[dict]: + """``scripts`` from a package.json. ``None`` when it will not parse.""" + try: + data = json.loads(text) + except (ValueError, TypeError): + return None + if not isinstance(data, dict): + return None + scripts = data.get("scripts") + return scripts if isinstance(scripts, dict) else {} + + +def _toml_sections(text: str) -> set: + """Section headers literally present in a TOML file. + + A deliberate literal-byte scan rather than a TOML parse: Python 3.9 has + no ``tomllib`` and this repo does not take new dependencies. We only + ever ask "is this section header in the file", which a scan answers + exactly. + """ + return { + m.group(1).strip() + for m in re.finditer(r"^\s*\[([^\[\]]+)\]\s*$", text, re.MULTILINE) + } + + +def _glob_any(root: str, subdir: str, suffixes: Iterable) -> list: + """Files directly under ``root/subdir`` with any of ``suffixes``.""" + out = [] + try: + with os.scandir(os.path.join(root, subdir)) as it: + for e in it: + if e.is_file() and any(e.name.endswith(s) for s in suffixes): + out.append(subdir + "/" + e.name) + except OSError: + return [] + return sorted(out) + + +def _walk_test_files(root: str, limit: int = 4000) -> bool: + """Does this repo contain Go test files? (``*_test.go``, capped walk.)""" + seen = 0 + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames + if not d.startswith(".") and d not in ("node_modules", "vendor")] + for fn in filenames: + seen += 1 + if seen > limit: + return False + if fn.endswith("_test.go"): + return True + return False + + +# ═══════════════════════════════════════════════════════════════════════════ +# The checks +# ═══════════════════════════════════════════════════════════════════════════ + +#: Catalog "memory" roots that are written BY the agent, not by a person. +#: The catalog is the right source for "where does this runtime read +#: project context", but a few of those roots are the runtime's own +#: transcript or scratch memory. A repo containing `.aider.input.history` +#: has not been documented for an agent; it has merely been used by one, and +#: passing the instruction check on it would be a false pass of exactly the +#: kind ADR-004 exists to stop. A denylist, not an allowlist, so a runtime +#: added tomorrow flows in graded rather than silently ignored. +_AGENT_WRITTEN_ROOTS = frozenset({ + ".aider.chat.history.md", # aider writes the chat transcript here + ".aider.input.history", # aider writes the prompt history here + ".goose/memory", # goose's own memory store + "memory", # OpenClaw agent memory directory + "MEMORY.md", # OpenClaw agent memory file +}) + + +def _instruction_roots(runtime: Optional[str]) -> list: + """Project-scoped instruction-file roots, derived from the catalog.""" + try: + from clawmetry import runtime_memory + roots = runtime_memory.project_relative_roots(["memory"]) + except Exception as e: # noqa: BLE001 — never break the score over this + log.debug("repo-readiness: catalog unavailable: %s", e) + return [] + roots = [r for r in roots if r["rel"] not in _AGENT_WRITTEN_ROOTS] + if runtime and runtime not in ("all", "any"): + roots = [r for r in roots if r["runtime"] == runtime] + return roots + + +def _rank_rels(roots: list) -> list: + """Instruction paths, most widely read first. + + The order is DERIVED: a file eleven runtimes read (``AGENTS.md``) is a + better thing to suggest than one a single runtime reads, and the catalog + already knows how many declare each. Hand-picking a favourite here would + be the drift this module exists to avoid. + """ + counts: dict = {} + for spec in roots: + counts[spec["rel"]] = counts.get(spec["rel"], 0) + 1 + return sorted(counts, key=lambda rel: (-counts[rel], rel)) + + +def _asset_roots(runtime: Optional[str]) -> list: + try: + from clawmetry import runtime_memory + roots = runtime_memory.project_relative_roots(["skills", "commands", "agents"]) + except Exception as e: # noqa: BLE001 + log.debug("repo-readiness: catalog unavailable: %s", e) + return [] + if runtime and runtime not in ("all", "any"): + roots = [r for r in roots if r["runtime"] == runtime] + return roots + + +def _present(root: str, rel: str) -> bool: + """Is this declared root present in the repo, with content?""" + full = os.path.join(root, rel.replace("/", os.sep)) + try: + if os.path.isdir(full): + return _isdir_nonempty(full) + if os.path.isfile(full): + return os.path.getsize(full) > 0 + except OSError: + return False + return False + + +def runtime_coverage(root: str, roots: Optional[list] = None) -> list: + """Which runtimes would find an instruction file in this repo. + + One row per runtime that declares any project-scoped instruction file, + with the files actually present. This is the per-runtime honesty layer: + a repo can be perfectly legible to Claude Code and invisible to Cursor, + and a single node-wide "has instructions" tick would hide that. + """ + roots = roots if roots is not None else _instruction_roots(None) + by_rt: dict = {} + for spec in roots: + row = by_rt.setdefault(spec["runtime"], { + "runtime": spec["runtime"], + "label": spec["runtime_label"], + "files": [], + "looked_for": [], + }) + row["looked_for"].append(spec["rel"]) + if _present(root, spec["rel"]): + row["files"].append(spec["rel"]) + out = [] + for row in by_rt.values(): + row["has_instructions"] = bool(row["files"]) + row["files"] = sorted(set(row["files"])) + row["looked_for"] = sorted(set(row["looked_for"])) + out.append(row) + out.sort(key=lambda r: (not r["has_instructions"], r["label"].lower())) + return out + + +def _check_instruction_file(root: str, runtime: Optional[str], + coverage: list) -> dict: + scoped = runtime and runtime not in ("all", "any") + found = sorted({f for row in coverage for f in row["files"]}) + if found: + if scoped: + detail = "%s reads %s in this repo." % ( + coverage[0]["label"] if coverage else runtime, + ", ".join(found[:4])) + else: + names = [r["label"] for r in coverage if r["has_instructions"]] + detail = "%s present; read by %s." % ( + ", ".join(found[:4]), + ", ".join(names[:4]) + (" and more" if len(names) > 4 else "")) + return _check( + "instruction_file", "Instruction file", PASS, detail, None, 25, + evidence=found[0], severity="critical") + looked = _rank_rels(_instruction_roots(runtime)) + if not looked: + return _check( + "instruction_file", "Instruction file", UNKNOWN, + "No runtime on this machine declares a project instruction file, " + "so there is nothing to look for.", None, 25, severity="critical") + hint = ", ".join(looked[:5]) + return _check( + "instruction_file", "Instruction file", FAIL, + "No instruction file in this repo. Looked for %s%s." + % (hint, " and %d more" % (len(looked) - 5) if len(looked) > 5 else ""), + "Add an instruction file at the repo root (%s) describing what the " + "project is, how to run it, and the conventions to follow." + % looked[0], + 25, severity="critical") + + +def _check_instruction_loaded(root: str, coverage: list, + loaded_evidence: Optional[dict]) -> dict: + """Did the runtime actually load the instruction file it found? + + A file on disk is not a file in the context window. Some runtimes could + report this; none of the runtimes ClawMetry observes report it today, + and we do not guess — reading the file ourselves proves only that WE + read it. So this is an explicit zero-weight unknown with the hook in + place: pass ``loaded_evidence`` and it grades for real. + """ + has_file = any(r["has_instructions"] for r in coverage) + if isinstance(loaded_evidence, dict) and loaded_evidence.get("observed"): + rts = loaded_evidence.get("runtimes") or [] + return _check( + "instruction_loaded", "Instruction file actually loaded", PASS, + "Reported loaded by %s." % (", ".join(rts) or "the runtime"), + None, 15, evidence=loaded_evidence.get("source"), severity="high") + if not has_file: + return _check( + "instruction_loaded", "Instruction file actually loaded", UNKNOWN, + "There is no instruction file to load yet.", None, 15, + severity="high") + return _check( + "instruction_loaded", "Instruction file actually loaded", UNKNOWN, + "The file is on disk, but no runtime on this machine reports which " + "context files it loaded, so we cannot confirm the agent read it. " + "Scored as unknown, not as a pass and not as a penalty.", + None, 15, severity="high") + + +def _check_test_command(root: str) -> dict: + """Is there a discoverable way to run this project's tests?""" + found: list = [] + partial: list = [] + unreadable: list = [] + + seen: set = set() + + def probe(rel, fn): + path = os.path.join(root, rel) + key = _probe_key(path) + if key in seen: + return + try: + text = _read_text(path) + except _Unreadable: + seen.add(key) + unreadable.append(rel) + return + if text is None: + return + seen.add(key) + fn(rel, text) + + def _mk(rel, text): + targets = _make_targets(text) + for name in ("test", "tests", "check"): + if name in targets: + found.append("%s: `make %s`" % (rel, name)) + return + + def _pkg(rel, text): + scripts = _package_scripts(text) + if scripts is None: + unreadable.append("%s (not valid JSON)" % rel) + return + if scripts.get("test"): + found.append("%s: `npm test`" % rel) + + def _pyproject(rel, text): + secs = _toml_sections(text) + if "tool.pytest.ini_options" in secs: + found.append("%s: [tool.pytest.ini_options]" % rel) + elif "tool.poetry" in secs or "project" in secs: + partial.append("%s declares a project but no test config" % rel) + + def _ini(rel, text): + if rel == "setup.cfg": + if "[tool:pytest]" in text: + found.append("%s: [tool:pytest]" % rel) + return + found.append(rel) + + def _cargo(rel, text): + partial.append("%s (`cargo test` is a Cargo default, not a project " + "choice we can verify)" % rel) + + def _gomod(rel, text): + if _walk_test_files(root): + found.append("%s + *_test.go: `go test ./...`" % rel) + else: + partial.append("%s but no *_test.go files found" % rel) + + probe("Makefile", _mk) + probe("makefile", _mk) + probe("GNUmakefile", _mk) + probe("Justfile", lambda r, t: (found.append("%s: `just test`" % r) + if re.search(r"^test\b", t, re.M) else None)) + probe("package.json", _pkg) + probe("pyproject.toml", _pyproject) + probe("pytest.ini", _ini) + probe("tox.ini", _ini) + probe("setup.cfg", _ini) + probe("Cargo.toml", _cargo) + probe("go.mod", _gomod) + + if found: + return _check( + "test_command", "Test command discoverable", PASS, + "An agent can find how to run the tests: %s." % "; ".join(found[:3]), + None, 20, evidence=found[0].split(":")[0], severity="high") + if partial: + return _check( + "test_command", "Test command discoverable", WARN, + "Only an inherited default: %s. An inherited default is " + "unmeasured, not ready." % "; ".join(partial[:2]), + "Add an explicit `test` target (Makefile) or `scripts.test` " + "(package.json) so the command is discoverable without guessing.", + 20, evidence=partial[0].split(" ")[0], severity="high") + if unreadable and not found: + return _check( + "test_command", "Test command discoverable", UNKNOWN, + "Could not read %s, so we cannot say either way." + % ", ".join(unreadable[:3]), None, 20, severity="high") + return _check( + "test_command", "Test command discoverable", FAIL, + "No test entry point an agent can find (checked Makefile, " + "package.json, pyproject.toml, pytest.ini, tox.ini, setup.cfg, " + "Cargo.toml, go.mod).", + "Add a `test` target to a Makefile, or `scripts.test` to " + "package.json, so the agent can verify its own work.", + 20, severity="high") + + +def _check_build_command(root: str) -> dict: + """Is there a discoverable way to build this project? + + We report whether the command can be FOUND, never whether it succeeds: + running a build changes the machine and usually reaches the network, + and this module does neither. + """ + found: list = [] + unreadable: list = [] + + seen: set = set() + + def probe(rel, fn): + path = os.path.join(root, rel) + key = _probe_key(path) + if key in seen: + return + try: + text = _read_text(path) + except _Unreadable: + seen.add(key) + unreadable.append(rel) + return + if text is not None: + seen.add(key) + fn(rel, text) + + def _mk(rel, text): + targets = _make_targets(text) + for name in ("build", "all", "install", "dist"): + if name in targets: + found.append("%s: `make %s`" % (rel, name)) + return + + def _pkg(rel, text): + scripts = _package_scripts(text) + if scripts is None: + unreadable.append("%s (not valid JSON)" % rel) + return + if scripts.get("build"): + found.append("%s: `npm run build`" % rel) + + def _pyproject(rel, text): + if "build-system" in _toml_sections(text): + found.append("%s: [build-system]" % rel) + + probe("Makefile", _mk) + probe("makefile", _mk) + probe("GNUmakefile", _mk) + probe("package.json", _pkg) + probe("pyproject.toml", _pyproject) + for rel in ("setup.py", "Dockerfile", "Cargo.toml", "go.mod", + "CMakeLists.txt", "build.gradle", "build.gradle.kts", + "pom.xml", "Gemfile"): + if _exists(os.path.join(root, rel)): + found.append(rel) + + if found: + return _check( + "build_command", "Build command discoverable", PASS, + "%s. Whether the build succeeds is not graded: ClawMetry never " + "runs your build." % "; ".join(found[:3]), + None, 10, evidence=found[0].split(":")[0]) + if unreadable: + return _check( + "build_command", "Build command discoverable", UNKNOWN, + "Could not read %s, so we cannot say either way." + % ", ".join(unreadable[:3]), None, 10) + return _check( + "build_command", "Build command discoverable", FAIL, + "No build entry point an agent can find (checked Makefile, " + "package.json, pyproject.toml, setup.py, Dockerfile and the usual " + "Cargo / Go / Gradle / Maven manifests).", + "Add a `build` target or script so the agent knows how to produce " + "an artifact without guessing.", + 10) + + +_LINT_CONFIG_FILES = ( + ".ruff.toml", "ruff.toml", ".flake8", ".pylintrc", "pylintrc", + ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", + ".eslintrc.yml", ".eslintrc.yaml", "eslint.config.js", + "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", + "biome.json", "biome.jsonc", ".golangci.yml", ".golangci.yaml", + ".rubocop.yml", ".pre-commit-config.yaml", ".pre-commit-config.yml", + ".swiftlint.yml", "rustfmt.toml", ".rustfmt.toml", ".stylelintrc", + ".clang-format", "checkstyle.xml", ".editorconfig", +) + + +def _check_lint_gate(root: str) -> dict: + found: list = [] + unreadable: list = [] + for rel in _LINT_CONFIG_FILES: + if _exists(os.path.join(root, rel)): + found.append(rel) + + seen: set = set() + + def probe(rel, fn): + path = os.path.join(root, rel) + key = _probe_key(path) + if key in seen: + return + try: + text = _read_text(path) + except _Unreadable: + seen.add(key) + unreadable.append(rel) + return + if text is not None: + seen.add(key) + fn(rel, text) + + def _mk(rel, text): + targets = _make_targets(text) + for name in ("lint", "fmt", "format", "check"): + if name in targets: + found.append("%s: `make %s`" % (rel, name)) + return + + def _pkg(rel, text): + scripts = _package_scripts(text) + if scripts is None: + unreadable.append("%s (not valid JSON)" % rel) + return + if scripts.get("lint") or scripts.get("format"): + found.append("%s: `npm run lint`" % rel) + + def _pyproject(rel, text): + secs = _toml_sections(text) + for name in ("tool.ruff", "tool.black", "tool.flake8", "tool.isort", + "tool.mypy", "tool.pylint"): + if any(s == name or s.startswith(name + ".") for s in secs): + found.append("%s: [%s]" % (rel, name)) + return + + probe("Makefile", _mk) + probe("makefile", _mk) + probe("package.json", _pkg) + probe("pyproject.toml", _pyproject) + + if found: + return _check( + "lint_gate", "Lint or format gate", PASS, + "A style gate the agent can run before it hands work back: %s." + % ", ".join(found[:3]), + None, 10, evidence=found[0].split(":")[0]) + if unreadable: + return _check( + "lint_gate", "Lint or format gate", UNKNOWN, + "Could not read %s, so we cannot say either way." + % ", ".join(unreadable[:3]), None, 10) + return _check( + "lint_gate", "Lint or format gate", FAIL, + "No lint or format config in the repo, and no `lint` target or " + "script.", + "Add a linter config (ruff, eslint, golangci-lint) or a `lint` " + "target. Without one the agent cannot tell whether its edit matches " + "the house style.", + 10) + + +_CI_FILES = ( + ".gitlab-ci.yml", ".gitlab-ci.yaml", ".circleci/config.yml", + "azure-pipelines.yml", "Jenkinsfile", ".travis.yml", ".drone.yml", + "bitbucket-pipelines.yml", "cloudbuild.yaml", "cloudbuild.yml", + ".woodpecker.yml", "wercker.yml", +) + + +def _check_ci_config(root: str) -> dict: + found = _glob_any(root, ".github/workflows", (".yml", ".yaml")) + for rel in _CI_FILES: + if _exists(os.path.join(root, rel)): + found.append(rel) + if _isdir_nonempty(os.path.join(root, ".buildkite")): + found.append(".buildkite/") + if found: + return _check( + "ci_config", "CI configuration", PASS, + "%d CI config%s in the repo (%s). An agent can read what " + "\"green\" means here." % ( + len(found), "" if len(found) == 1 else "s", + ", ".join(found[:3])), + None, 10, evidence=found[0]) + return _check( + "ci_config", "CI configuration", FAIL, + "No CI configuration found (checked .github/workflows and the usual " + "GitLab / CircleCI / Azure / Jenkins / Travis files).", + "Add a CI workflow. It is the only place that states, in a form an " + "agent can read, which checks have to pass.", + 10) + + +def _check_agent_assets(root: str, runtime: Optional[str]) -> dict: + """Skills / commands / sub-agent definitions the runtime would discover. + + Absent is graded ``warn``, not ``fail``: most healthy repos ship none, + and a check that fails on a healthy repo teaches the reader to ignore + the grade. + """ + roots = _asset_roots(runtime) + if not roots: + return _check( + "agent_assets", "Skills and commands", UNKNOWN, + "No runtime on this machine declares project-scoped skills, so " + "there is nothing to look for.", None, 10) + found = [spec["rel"] for spec in roots if _present(root, spec["rel"])] + found = sorted(set(found)) + if found: + return _check( + "agent_assets", "Skills and commands", PASS, + "The repo ships agent assets the runtime will discover: %s." + % ", ".join(found[:4]), + None, 10, evidence=found[0], severity="low") + # Suggest a directory-shaped root (".claude/skills") over a bare name + # ("skills"): a one-word hint at the repo root reads as a typo. + hint = next((r["rel"] for r in roots if "/" in r["rel"]), roots[0]["rel"]) + return _check( + "agent_assets", "Skills and commands", WARN, + "No repo-scoped skills, slash commands or sub-agent definitions. " + "That is normal, and it is also the cheapest thing to add.", + "Repeated multi-step work in this repo is worth packaging as a " + "skill (%s/) so every session starts from it." % hint, + 10, severity="low") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Scoring +# ═══════════════════════════════════════════════════════════════════════════ + +_GRADES = ( + (90, "A", "Ready", "#22c55e"), + (75, "B", "Good", "#84cc16"), + (60, "C", "Fair", "#f59e0b"), + (40, "D", "Thin", "#f97316"), + (0, "F", "Bare", "#ef4444"), +) + + +def _grade(checks: list) -> tuple: + """pass = full weight, warn = half, fail = zero, unknown = not counted. + + ``unknown`` checks carry weight 0 (forced in :func:`_check`), so they + fall out of both the numerator and the denominator: a thing we cannot + read can move the grade in neither direction. + """ + total = sum(c["weight"] for c in checks) + earned = sum(c["weight"] for c in checks if c["status"] == PASS) + earned += sum(c["weight"] * 0.5 for c in checks if c["status"] == WARN) + if total <= 0: + return "U", "Not scored", "#64748b", 0.0 + pct = earned / total * 100 + for floor, letter, label, color in _GRADES: + if pct >= floor: + return letter, label, color, round(pct, 1) + return "F", "Bare", "#ef4444", round(pct, 1) + + +def git_root(path: str) -> Optional[str]: + """Nearest ancestor of *path* holding a ``.git`` entry. + + Pure filesystem walk — no ``git`` subprocess, so this costs a handful + of stats and cannot reach the network. Handles worktrees and submodules, + where ``.git`` is a file rather than a directory. + """ + if not isinstance(path, str) or not path.strip(): + return None + try: + cur = os.path.abspath(os.path.expanduser(path.strip())) + except (OSError, ValueError): + return None + for _ in range(_MAX_GIT_WALK): + try: + if os.path.exists(os.path.join(cur, ".git")): + return cur + except OSError: + return None + parent = os.path.dirname(cur) + if parent == cur: + return None + cur = parent + return None + + +def score_repo(path: str, *, runtime: Optional[str] = None, + signals: Optional[dict] = None, + loaded_evidence: Optional[dict] = None) -> dict: + """Score one repository. Never raises, never opens a socket. + + ``runtime`` scopes the instruction / asset checks to one runtime, so a + repo legible to Claude Code but invisible to Cursor reads honestly under + the runtime switcher. ``signals`` is the already-measured stuck-rate + bundle from :func:`pair_signals`; it is displayed next to the score and + never folded into it. + """ + # An empty / non-string path must NOT quietly resolve to the process cwd: + # os.path.abspath("") returns it, so a caller that lost its path would + # score whatever directory the dashboard happens to be running in and + # present it as the user's repo. + root = None + if isinstance(path, str) and path.strip(): + try: + root = os.path.abspath(os.path.expanduser(path.strip())) + except (OSError, ValueError): + root = None + if not root or not os.path.isdir(root): + return { + "status": "not_found", + "path": path, + "detail": "That directory is not on this machine.", + "checks": [], "score": "U", "score_label": "Not scored", + "score_color": "#64748b", "score_pct": 0.0, + "passed": 0, "failed": 0, "warnings": 0, "unknowns": 0, + "total": 0, "signals": signals or _empty_signals(), + "runtime_coverage": [], + "scanned_at": datetime.now().isoformat(), + } + + coverage = runtime_coverage(root, _instruction_roots(runtime)) + checks = [ + _check_instruction_file(root, runtime, coverage), + _check_instruction_loaded(root, coverage, loaded_evidence), + _check_test_command(root), + _check_build_command(root), + _check_lint_gate(root), + _check_ci_config(root), + _check_agent_assets(root, runtime), + ] + letter, label, color, pct = _grade(checks) + return { + "status": "ok", + "path": root, + "name": os.path.basename(root) or root, + "is_git_repo": _exists(os.path.join(root, ".git")), + "runtime": runtime or "all", + "score": letter, + "score_label": label, + "score_color": color, + "score_pct": pct, + "checks": checks, + "passed": sum(1 for c in checks if c["status"] == PASS), + "failed": sum(1 for c in checks if c["status"] == FAIL), + "warnings": sum(1 for c in checks if c["status"] == WARN), + "unknowns": sum(1 for c in checks if c["status"] == UNKNOWN), + "total": len(checks), + "runtime_coverage": coverage, + "signals": signals if signals is not None else _empty_signals(), + "scanned_at": datetime.now().isoformat(), + } + + +# ═══════════════════════════════════════════════════════════════════════════ +# The pairing: what actually happened to agents working in this repo +# ═══════════════════════════════════════════════════════════════════════════ + +_SIGNAL_KINDS = ("stuck_loop", "no_progress", "repeated_tool_failure", + "action_discrepancy") + +#: ``loop_signals.signature`` → detector class, mirroring +#: ``sync._LOOPS_KIND_BY_SIGNATURE`` (the daemon's own mapping). A signature +#: we do not recognise is still a genuine loop (the proxy LoopDetector writes +#: a request hash), so it counts as ``stuck_loop`` rather than being dropped. +_KIND_BY_SIGNATURE = { + "daemon_stuck": "stuck_loop", + "daemon_detect_stuck_loop": "stuck_loop", + "daemon_detect_no_progress": "no_progress", + "daemon_detect_repeated_tool_failure": "repeated_tool_failure", + "daemon_detect_action_discrepancy": "action_discrepancy", +} + + +def _empty_signals() -> dict: + return { + "sessions": 0, + "stuck_sessions": 0, + "stuck_rate": None, + "incidents": {k: 0 for k in _SIGNAL_KINDS}, + "window_days": 0, + "has_history": False, + } + + +def signal_kind(signature: str, details: Any) -> Optional[str]: + """Classify one ``loop_signals`` row. ``None`` when it is not a loop.""" + if isinstance(details, str): + try: + details = json.loads(details) + except (ValueError, TypeError): + details = None + if isinstance(details, dict): + kind = str(details.get("kind") or "").strip() + if kind in _SIGNAL_KINDS: + return kind + sig = str(signature or "") + kind = _KIND_BY_SIGNATURE.get(sig) + if kind: + return kind + return "stuck_loop" if sig else None + + +def pair_signals(rows: Iterable, *, window_days: int) -> dict: + """Fold ``query_repo_activity`` rows for ONE repo into the pairing block. + + Each row is one session that ran in this repo, optionally carrying the + loop signal the detector wrote for it. ``stuck_rate`` is ``None`` — not + ``0.0`` — when there are no sessions, because "no agent has worked here" + and "every agent sailed through" are different facts. + """ + sessions = set() + stuck = set() + incidents = {k: 0 for k in _SIGNAL_KINDS} + for row in rows or (): + if not isinstance(row, dict): + continue + sid = str(row.get("session_id") or "").strip() + if not sid: + continue + sessions.add(sid) + kind = signal_kind(row.get("signature"), row.get("details")) + if kind: + stuck.add(sid) + incidents[kind] += 1 + n = len(sessions) + return { + "sessions": n, + "stuck_sessions": len(stuck), + "stuck_rate": round(len(stuck) / n * 100, 1) if n else None, + "incidents": incidents, + "window_days": int(window_days), + "has_history": n > 0, + } + + +def group_by_repo(rows: Iterable) -> "dict[str, list]": + """Bucket ``query_repo_activity`` rows by the git root of their ``cwd``. + + A session that ran three directories deep in a checkout belongs to that + checkout, not to its own subdirectory. Rows whose ``cwd`` no longer + exists on this machine fall back to the recorded path so a deleted + checkout still reports its history instead of vanishing. + """ + out: dict = {} + for row in rows or (): + if not isinstance(row, dict): + continue + cwd = str(row.get("cwd") or "").strip() + if not cwd: + continue + root = git_root(cwd) or cwd + out.setdefault(root, []).append(row) + return out + + +def rank_repos(rows: Iterable, *, window_days: int, limit: int = 25) -> list: + """Repos an agent has actually worked in, busiest first. + + Returns ``[{path, name, exists, signals, last_active_at}, …]``. Pure — + the caller decides which ones to score. + """ + out = [] + for root, group in group_by_repo(rows).items(): + last = "" + for row in group: + ts = str(row.get("last_active_at") or "") + if ts > last: + last = ts + out.append({ + "path": root, + "name": os.path.basename(root) or root, + "exists": os.path.isdir(root), + "last_active_at": last or None, + "signals": pair_signals(group, window_days=window_days), + }) + out.sort(key=lambda r: (-(r["signals"]["sessions"]), + r["last_active_at"] or "", r["path"]), + reverse=False) + out.sort(key=lambda r: (r["signals"]["sessions"], + r["last_active_at"] or ""), reverse=True) + return out[:max(1, int(limit))] diff --git a/clawmetry/runtime_memory.py b/clawmetry/runtime_memory.py index 81ac39cacc..a21ebbb9be 100644 --- a/clawmetry/runtime_memory.py +++ b/clawmetry/runtime_memory.py @@ -1587,3 +1587,63 @@ def read_runtime_file(runtime_id: str, root: str, path: str, "binary": binary, "truncated": size > len(raw), } + + +# ── Project-relative root contract (consumed by repo_readiness) ───────────── +# +# ``clawmetry/repo_readiness.py`` scores an arbitrary code repo on how legible +# it is to an agent. The set of files a runtime reads INSIDE a repo +# (``CLAUDE.md``, ``AGENTS.md``, ``.cursor/rules/``, ``.github/prompts/``, …) +# is already declared once, here, as the ``scope="project"`` RootSpecs. This +# helper exposes those declarations as repo-relative paths so the scorer +# DERIVES its file list from the catalog instead of hand-maintaining a second +# copy that would silently drift every time a runtime is added. +# +# Only roots that live at or under the workspace root are returned: the +# ``_expand_project_roots`` clones point at OTHER checkouts and are not part of +# the per-repo contract. + +def project_relative_roots(categories: Optional[Iterable] = None) -> list: + """Every ``scope="project"`` root as a repo-relative path. + + Returns ``[{runtime, runtime_label, category, rel, label, globs}, …]`` + where ``rel`` is the path relative to the repo root (e.g. ``CLAUDE.md``, + ``.claude/skills``). Deduped, stably ordered, never raises. + """ + wanted = parse_categories(categories) if categories is not None else set(CATEGORIES) + try: + ws = os.path.abspath(_workspace_root()) + except OSError: + return [] + out: list = [] + seen = set() + try: + catalog = _catalog() + except Exception: + return [] + for entry in catalog: + for spec in entry.roots: + if spec.scope != "project" or spec.category not in wanted: + continue + try: + root = os.path.abspath(spec.expanded_root()) + rel = os.path.relpath(root, ws) + except (OSError, ValueError): + continue + # Skip clones that live outside this repo, and the degenerate + # "the repo root itself is the root" case. + if rel == os.curdir or rel.startswith(os.pardir) or os.path.isabs(rel): + continue + key = (entry.id, spec.category, rel) + if key in seen: + continue + seen.add(key) + out.append({ + "runtime": entry.id, + "runtime_label": entry.label, + "category": spec.category, + "rel": rel.replace(os.sep, "/"), + "label": spec.label or os.path.basename(rel), + "globs": tuple(spec.include_globs or ()), + }) + return out diff --git a/clawmetry/static/css/dashboard.css b/clawmetry/static/css/dashboard.css index 7132428517..efaa1af4fc 100644 --- a/clawmetry/static/css/dashboard.css +++ b/clawmetry/static/css/dashboard.css @@ -3014,3 +3014,120 @@ body.has-profile-menu #logout-btn { display: none !important; } @media (max-width: 640px) { .cm-needs-where { display: none; } } + +/* ── Repo AI-readiness (Harness tab) ────────────────────────────────────── + "The handoff sheet": the grade a colleague would leave on the desk before + handing you the repo. The signature element is the weight bar, which draws + the ADR-004 grading rule instead of only asserting it: every counted check + is a segment sized by its weight, and anything we could not read sits + OUTSIDE the bar, hatched, labelled "not counted". A reader can see at a + glance that an unknown moved the grade in neither direction. + Monospace is reserved for literal things on disk (paths, evidence), so the + typeface itself says "we opened this file". */ +.rr-card { padding: 0; margin-bottom: 14px; overflow: hidden; } +.rr-head { + display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; + justify-content: space-between; + padding: 16px 18px 14px; border-bottom: 1px solid var(--border-secondary); +} +.rr-title { font-size: 16px; font-weight: 700; letter-spacing: -0.01em; } +.rr-sub { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; max-width: 60ch; } +.rr-head-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.rr-pick-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); } +.rr-pick { + max-width: 320px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12px; padding: 5px 8px; border-radius: 6px; + border: 1px solid var(--border-primary); background: var(--bg-tertiary); color: var(--text-secondary); +} +.rr-body { padding: 16px 18px 18px; } + +/* Verdict: one plain sentence joining the grade to what actually happened. */ +.rr-verdict { + font-size: 15px; line-height: 1.55; color: var(--text-secondary); + margin-bottom: 16px; max-width: 72ch; +} +.rr-verdict b { color: var(--text-primary); font-weight: 700; } + +.rr-top { display: flex; flex-wrap: wrap; gap: 22px; align-items: flex-start; } + +/* Grade block. A stacked letter, not a donut. */ +.rr-grade { flex: 0 0 auto; min-width: 150px; } +.rr-letter { font-size: 54px; font-weight: 800; line-height: 0.9; letter-spacing: -0.04em; } +.rr-grade-label { font-size: 12.5px; font-weight: 600; margin-top: 4px; } +.rr-grade-pct { font-size: 11.5px; color: var(--text-faint); margin-top: 2px; } + +/* The weight bar + the not-counted tail. */ +.rr-bar-wrap { flex: 1 1 300px; min-width: 260px; } +.rr-bar { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--bg-hover); } +.rr-seg { height: 100%; } +.rr-seg + .rr-seg { border-left: 1px solid var(--bg-secondary); } +.rr-uncounted { display: flex; align-items: center; gap: 7px; margin-top: 8px; } +.rr-hatch { + width: 34px; height: 10px; border-radius: 5px; flex: 0 0 auto; + border: 1px dashed var(--border-primary); + background: repeating-linear-gradient(45deg, + var(--bg-hover) 0 3px, transparent 3px 6px); +} +.rr-uncounted-text { font-size: 11.5px; color: var(--text-faint); } +.rr-legend { display: flex; flex-wrap: wrap; gap: 10px 14px; margin-top: 10px; font-size: 11.5px; color: var(--text-muted); } +.rr-legend span { display: inline-flex; align-items: center; gap: 5px; } +.rr-dot { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } + +/* What actually happened here. */ +.rr-signals { flex: 1 1 240px; min-width: 220px; } +.rr-signals-h { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); margin-bottom: 8px; } +.rr-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.rr-chip { + display: inline-flex; align-items: baseline; gap: 6px; + font-size: 12px; padding: 4px 9px; border-radius: 999px; + border: 1px solid var(--border-primary); background: var(--bg-tertiary); color: var(--text-tertiary); +} +.rr-chip b { font-size: 13px; color: var(--text-primary); } +.rr-chip.rr-chip-zero { opacity: 0.55; } +.rr-chip.rr-chip-hot { border-color: var(--text-warning); color: var(--text-warning); } +.rr-chip.rr-chip-hot b { color: var(--text-warning); } + +/* Check rows. */ +.rr-checks { margin-top: 20px; border-top: 1px solid var(--border-secondary); } +.rr-check { display: flex; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border-secondary); } +.rr-check:last-child { border-bottom: none; } +.rr-glyph { flex: 0 0 auto; width: 18px; text-align: center; font-size: 13px; line-height: 1.5; font-weight: 700; } +.rr-check-main { flex: 1 1 auto; min-width: 0; } +.rr-check-h { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; } +.rr-check-label { font-size: 13.5px; font-weight: 600; } +.rr-weight { font-size: 11px; color: var(--text-faint); } +.rr-detail { font-size: 12.5px; color: var(--text-muted); line-height: 1.55; margin-top: 3px; } +.rr-fix { font-size: 12.5px; color: var(--text-tertiary); line-height: 1.55; margin-top: 5px; } +.rr-fix::before { content: "Fix: "; font-weight: 600; color: var(--text-secondary); } +.rr-evidence { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; color: var(--text-faint); margin-top: 5px; + /* anywhere, not break-all: a long path wraps at a slash instead of + splitting the sentence after it mid-word ("not hing"). */ + overflow-wrap: anywhere; word-break: normal; +} + +/* Which runtimes would find their instructions here. */ +.rr-cov { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-secondary); } +.rr-cov-h { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); margin-bottom: 8px; } +.rr-cov-pills { display: flex; flex-wrap: wrap; gap: 6px; } +.rr-pill { + font-size: 11.5px; padding: 3px 9px; border-radius: 999px; + border: 1px solid var(--border-primary); background: var(--bg-tertiary); color: var(--text-faint); +} +.rr-pill.on { border-color: var(--text-success); color: var(--text-success); background: var(--bg-success); } +.rr-empty { font-size: 13px; color: var(--text-muted); line-height: 1.6; max-width: 68ch; } + +@media (max-width: 620px) { + .rr-top { gap: 16px; } + .rr-letter { font-size: 44px; } + .rr-pick { max-width: 100%; } +} +@media (prefers-reduced-motion: reduce) { + .rr-card * { transition: none !important; animation: none !important; } +} +.rr-scope-note { + font-size: 12px; color: var(--text-warning); background: var(--bg-warning); + border: 1px solid var(--border-primary); border-radius: 6px; + padding: 7px 11px; margin-bottom: 14px; max-width: 72ch; +} diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js index 055d5d6429..2d48e3e090 100644 --- a/clawmetry/static/js/app.js +++ b/clawmetry/static/js/app.js @@ -21140,6 +21140,248 @@ async function saveClaudeCoverageKey() { } } +// ── Repo AI-readiness ───────────────────────────────────────────────────── +// Before you blame the agent, look at what you handed it. Scores the repo an +// agent actually worked in on how legible it is, and puts that grade next to +// the stuck-loop counts the detectors recorded for the same repo. +// +// Free and ungated. Every figure is a filesystem fact or a DuckDB row; the +// renderer never invents one. Two honesty rules are load-bearing here and +// must survive any edit: +// 1. An `unknown` check is drawn OUTSIDE the weight bar, hatched, labelled +// "not counted". It carries weight 0 and must never be shaded as if it +// passed or failed. +// 2. `stuck_rate === null` means no agent has worked here. It renders as +// "nothing to compare yet", never as 0%. +var _cmReadinessPath = ''; +var _cmReadinessBusy = false; + +async function loadRepoReadiness(path) { + var body = document.getElementById('rr-body'); + if (!body) return; + if (typeof path === 'string' && path) _cmReadinessPath = path; + if (_cmReadinessBusy) return; + _cmReadinessBusy = true; + var rt = (typeof _cmRuntimeFilter === 'function') ? _cmRuntimeFilter() : 'all'; + var url = '/api/repo-readiness?days=30'; + if (_cmReadinessPath) url += '&path=' + encodeURIComponent(_cmReadinessPath); + if (rt && rt !== 'all') url += '&runtime=' + encodeURIComponent(rt); + try { + // fetchJsonWithTimeout, not a bare fetch: on a busy node the daemon + // serialises DuckDB reads and a plain fetch never settles, which pins the + // card on "Scanning the repo..." forever with no way back. Same helper and + // budget the rest of the app uses. + var data = await fetchJsonWithTimeout(url, 25000); + _cmRenderReadinessPicker(data); + body.innerHTML = _cmRenderReadiness(data); + } catch (e) { + var why = String((e && e.message) || e); + body.innerHTML = '
' + + (/abort|timeout/i.test(why) + ? 'The scan is taking longer than usual, most likely because the ' + + 'agent database is busy. Nothing is wrong with your repo.' + : 'Could not scan the repo: ' + escapeHtml(why)) + + ' Try again
'; + } finally { + _cmReadinessBusy = false; + } +} + +function _cmRenderReadinessPicker(data) { + var sel = document.getElementById('rr-repo-pick'); + if (!sel) return; + var repos = (data && data.repos) || []; + var current = (data && data.report && data.report.path) || ''; + if (!repos.length) { + sel.style.display = 'none'; + var lbl = document.querySelector('.rr-pick-label'); + if (lbl) lbl.style.display = 'none'; + return; + } + sel.style.display = ''; + sel.innerHTML = repos.map(function (r) { + var n = r.signals && r.signals.sessions; + var suffix = n ? ' (' + n + ' session' + (n === 1 ? '' : 's') + ')' : ''; + var gone = r.exists ? '' : ' [not on this machine]'; + return ''; + }).join(''); +} + +function _cmReadinessVerdict(rep, days) { + // One plain sentence joining the grade to what actually happened here. + var sig = rep.signals || {}; + var head = 'Graded ' + escapeHtml(rep.score) + ' · ' + + escapeHtml(rep.score_label) + '.'; + if (!sig.has_history) { + return head + ' No agent session on this machine has run in this repo yet, ' + + 'so there is nothing to compare the grade against.'; + } + var n = sig.sessions, stuck = sig.stuck_sessions; + var tail; + if (!stuck) { + tail = ' Agents ran ' + n + ' session' + (n === 1 ? '' : 's') + + ' here in the last ' + days + ' days and none of them got stuck.'; + } else { + tail = ' Agents ran ' + n + ' session' + (n === 1 ? '' : 's') + + ' here in the last ' + days + ' days, and ' + stuck + ' of them ' + + 'got stuck (' + sig.stuck_rate + '%).'; + } + return head + tail; +} + +var _CM_RR_COLORS = { pass: '#22c55e', warn: '#f59e0b', fail: '#ef4444' }; +var _CM_RR_GLYPH = { pass: '✓', warn: '!', fail: '✕', unknown: '?' }; +var _CM_RR_SIGNAL_LABEL = { + stuck_loop: 'Stuck loops', + no_progress: 'No progress', + repeated_tool_failure: 'Repeated tool failures', + action_discrepancy: 'Carried on after a failure' +}; + +function _cmRenderReadiness(data) { + if (!data || data.status === 'error') { + return '
Could not scan the repo: ' + + escapeHtml((data && data.detail) || 'unknown error') + '
'; + } + if (data.status === 'no_repo' || !data.report) { + // Two different empty states. With repos in the picker, the selected one + // is a checkout that is gone from the machine that scanned it; telling + // that reader to "run an agent inside a code repo" would be nonsense. + var hasOthers = data.repos && data.repos.length; + return '
' + + escapeHtml(data.detail || (hasOthers + ? 'That checkout is no longer on the machine that scanned it, so there ' + + 'is nothing left to read. Its history is still in the picker above.' + : 'Nothing to score yet.')) + + (hasOthers ? ' Pick another repo above.' + : ' Run an agent inside a code repo and this fills in on its own.') + + '
'; + } + var rep = data.report; + if (rep.status === 'not_found') { + return '
That repo is no longer on this machine, so ' + + 'there is nothing to read. Its session history is still in the picker ' + + 'above.
'; + } + var days = data.window_days || 30; + var checks = rep.checks || []; + var counted = checks.filter(function (c) { return c.weight > 0; }); + var unknown = checks.filter(function (c) { return c.status === 'unknown'; }); + var totalW = counted.reduce(function (a, c) { return a + c.weight; }, 0) || 1; + + var html = ''; + // Per-runtime honesty (FLYWHEEL 0a.2). The hosted card is served from a + // snapshot the daemon scored against EVERY runtime's declared files, + // because the daemon cannot know which runtime the viewer picked. When a + // runtime filter is on and the payload says all_runtimes, say so out loud + // rather than letting node-wide data read as runtime-scoped. + var rtSel = (typeof _cmRuntimeFilter === 'function') ? _cmRuntimeFilter() : 'all'; + if (data.scope === 'all_runtimes' && rtSel && rtSel !== 'all') { + html += '
Scored against every runtime, not just ' + + escapeHtml(rtSel) + '. This card comes from the snapshot your machine ' + + 'uploaded, and that scan does not know which runtime you have selected.' + + '
'; + } + html += '
' + _cmReadinessVerdict(rep, days) + '
'; + html += '
'; + + // Grade block. + html += '
' + + '
' + + escapeHtml(rep.score) + '
' + + '
' + + escapeHtml(rep.score_label) + '
' + + '
' + rep.score_pct + '% of the checks that count
' + + '
'; + + // Weight bar: one segment per counted check, width = its share of the grade. + // Warn is drawn at half opacity because it earns half credit. + html += '
'; + counted.forEach(function (c) { + var col = _CM_RR_COLORS[c.status] || '#64748b'; + var op = c.status === 'warn' ? '0.55' : '1'; + html += '
'; + }); + html += '
'; + if (unknown.length) { + html += '
' + + '' + unknown.length + ' check' + + (unknown.length === 1 ? '' : 's') + ' we could not read. Not counted, ' + + 'in either direction.
'; + } + html += '
' + + 'Ready' + + 'Half credit' + + 'Missing' + + '
'; + + // What actually happened in this repo. + html += '
What happened here
'; + var sig = rep.signals || {}; + if (!sig.has_history) { + html += '
No sessions recorded ' + + 'in this repo yet.
'; + } else { + var inc = sig.incidents || {}; + html += '
'; + Object.keys(_CM_RR_SIGNAL_LABEL).forEach(function (k) { + var n = inc[k] || 0; + html += '' + + '' + n + '' + escapeHtml(_CM_RR_SIGNAL_LABEL[k]) + ''; + }); + html += '
'; + } + html += '
'; + + // The checks. + html += '
'; + checks.forEach(function (c) { + var col = c.status === 'unknown' ? 'var(--text-faint)' + : (_CM_RR_COLORS[c.status] || 'var(--text-faint)'); + html += '
' + + '
' + + (_CM_RR_GLYPH[c.status] || '?') + '
' + + '
' + + '
' + + escapeHtml(c.label) + '' + + '' + + (c.weight > 0 ? 'worth ' + c.weight + ' points' : 'not counted') + + '
' + + '
' + escapeHtml(c.detail || '') + '
'; + if (c.remediation) { + html += '
' + escapeHtml(c.remediation) + '
'; + } + if (c.evidence) { + html += '
read from ' + escapeHtml(c.evidence) + '
'; + } + html += '
'; + }); + html += '
'; + + // Per-runtime honesty: a repo can be legible to one runtime and invisible + // to another, and a single node-wide tick would hide that. + var cov = rep.runtime_coverage || []; + if (cov.length > 1) { + html += '
Which runtimes would find ' + + 'their instructions here
'; + cov.forEach(function (r) { + html += '' + escapeHtml(r.label) + ''; + }); + html += '
'; + } + + html += '
Scanned ' + + escapeHtml(rep.path) + '. Nothing was run and nothing left this machine.
'; + return html; +} + async function loadHarness() { var el = document.getElementById('harness-container'); if (!el) return; @@ -21151,6 +21393,9 @@ async function loadHarness() { var _cov = document.getElementById('claude-coverage'); if (rt === 'claude_code') { loadClaudeCoverage(); } else if (_cov) { _cov.style.display = 'none'; _cov.innerHTML = ''; } + // Repo readiness is runtime-scoped (a repo legible to Claude Code can be + // invisible to Cursor), so it re-fetches with the switcher, like the panel. + loadRepoReadiness(); try { if (!_cmHarnessTemplates) { var t = await fetch('/api/harness/templates').then(function (r) { return r.json(); }); diff --git a/clawmetry/sync.py b/clawmetry/sync.py index 2dfc7f39df..910b956f8b 100644 --- a/clawmetry/sync.py +++ b/clawmetry/sync.py @@ -19537,6 +19537,73 @@ def _emit_detector_incidents(store, state: dict) -> int: _LOOPS_VALID_KINDS = frozenset(_LOOPS_KIND_BY_SIGNATURE.values()) +#: Repo AI-readiness snapshot slice (WO-5). The hosted dashboard has no +#: filesystem to scan -- the cloud container never sees the user's repos -- so +#: the DAEMON scores them here and ships the finished report. Capped hard: +#: five reports at roughly 3 kB each is a rounding error next to the snapshot, +#: and a 200-repo machine must not be able to inflate it. +_READINESS_SLICE_MAX = int(os.environ.get("CLAWMETRY_READINESS_SLICE_MAX", "5")) +_READINESS_WINDOW_DAYS = int(os.environ.get("CLAWMETRY_READINESS_DAYS", "30")) + + +def _build_repo_readiness_slice(store): + """Score the repos this node's agents actually work in. + + Returns ``{"windowDays": n, "scope": "all_runtimes", "repos": [...]}`` + where each repo carries its readiness report AND the stuck-signal + pairing. ``scope`` is load-bearing: the daemon scores against EVERY + runtime's declared instruction files because it cannot know which + runtime the viewer has selected, so a hosted renderer must label this + "all runtimes" rather than presenting it as runtime-scoped. + + Repos that no longer exist on disk are listed (their history is real) + but carry ``report: None`` -- there is nothing left to read, and an + invented grade for a deleted checkout is worse than an honest gap. + + Never raises: an empty slice paints the honest "nothing scored yet" + state rather than breaking the snapshot. + """ + try: + from clawmetry import repo_readiness + except Exception as e: # noqa: BLE001 + log.debug("readiness-slice: module unavailable: %s", e) + return {} + try: + rows = store.query_repo_activity( + since_days=_READINESS_WINDOW_DAYS, limit=5000) or [] + except Exception as e: # noqa: BLE001 + log.debug("readiness-slice: query_repo_activity failed: %s", e) + return {} + + ranked = repo_readiness.rank_repos( + rows, window_days=_READINESS_WINDOW_DAYS, limit=_READINESS_SLICE_MAX) + out = [] + for repo in ranked: + entry = { + "path": repo["path"], + "name": repo["name"], + "exists": repo["exists"], + "lastActiveAt": repo["last_active_at"], + "signals": repo["signals"], + "report": None, + } + if repo["exists"]: + try: + entry["report"] = repo_readiness.score_repo( + repo["path"], signals=repo["signals"]) + except Exception as e: # noqa: BLE001 + log.debug("readiness-slice: score failed for %s: %s", + repo["path"], e) + out.append(entry) + if not out: + return {} + return { + "windowDays": _READINESS_WINDOW_DAYS, + "scope": "all_runtimes", + "repos": out, + } + + def _build_loops_slice(store): """Build the bounded, plaintext ``loops[]`` snapshot slice from the loop signals the detector/stuck pass already wrote. @@ -20647,6 +20714,20 @@ def sync_system_snapshot(config: dict, state: dict, paths: dict) -> int: except Exception as _e_loops: log.debug("snapshot: loops slice failed: %s", _e_loops) + # Repo AI-readiness (WO-5). Scored HERE, on the daemon, because the cloud + # container has no filesystem to scan: every input is a file in a repo on + # this machine. Same store handle as above (never a read_only re-open -- + # FLYWHEEL section 1). Empty dict == nothing scored, which the hosted card + # renders as an honest empty state. + _readiness_slice: dict = {} + try: + from clawmetry import local_store as _ls_rr + _rr_store = _ls_rr.get_store() + if _rr_store is not None: + _readiness_slice = _build_repo_readiness_slice(_rr_store) + except Exception as _e_rr: + log.debug("snapshot: repo-readiness slice failed: %s", _e_rr) + from clawmetry.providers_pricing import provider_for_model as _pfm payload = { "system": system, @@ -20671,6 +20752,11 @@ def sync_system_snapshot(config: dict, state: dict, paths: dict) -> int: # path strips the id). Self-clearing 30-min window; empty == nothing # looping. Sourced from the detector pass's loop_signals (no recompute). "loops": _loops_slice, + # WO-5: per-repo readiness grade + the stuck-signal pairing, scored on + # this machine because the cloud has no repo to read. Carries + # ``scope: "all_runtimes"`` so a hosted renderer labels it instead of + # passing node-wide data off as runtime-scoped. + "repoReadiness": _readiness_slice, "subagentCounts": { "active": active_count, "idle": len([s for s in subagents_list if s["status"] == "idle"]), diff --git a/clawmetry/templates/tabs/harness.html b/clawmetry/templates/tabs/harness.html index 43543d9b0c..58ee34b5f2 100644 --- a/clawmetry/templates/tabs/harness.html +++ b/clawmetry/templates/tabs/harness.html @@ -66,6 +66,27 @@ + +
+
+
+
Is this repo ready for an agent?
+
Agents get stuck on repos that do not explain themselves. This is what yours tells them.
+
+
+ + + +
+
+
Scanning the repo…
+
+ diff --git a/dashboard.py b/dashboard.py index cab399a775..230b04ac44 100644 --- a/dashboard.py +++ b/dashboard.py @@ -105,6 +105,7 @@ from routes.usage import bp_usage from routes.crons import bp_crons from routes.harness import bp_harness +from routes.readiness import bp_readiness from routes.health import bp_health from routes.alerts import bp_alerts, bp_budget from routes.channels import bp_channels @@ -12376,6 +12377,7 @@ def detect_config(args=None): app.register_blueprint(bp_fleet) app.register_blueprint(bp_gateway) app.register_blueprint(bp_harness) + app.register_blueprint(bp_readiness) app.register_blueprint(bp_health) app.register_blueprint(bp_logs) app.register_blueprint(bp_memory) diff --git a/docs/ac_coverage_baseline.json b/docs/ac_coverage_baseline.json index e6cc4ed399..7815c73139 100644 --- a/docs/ac_coverage_baseline.json +++ b/docs/ac_coverage_baseline.json @@ -8,8 +8,8 @@ "after coverage improves, so the ratchet is tightened in the same PR.", "Regenerate with: python3 scripts/check_ac_coverage.py --update-baseline" ], - "covered_count": 50, - "total_count": 115, + "covered_count": 51, + "total_count": 116, "uncovered": [ "AC-GOV-001.1", "AC-GOV-001.2", diff --git a/docs/acceptance_criteria.json b/docs/acceptance_criteria.json index 335092a500..1ad5d40a25 100644 --- a/docs/acceptance_criteria.json +++ b/docs/acceptance_criteria.json @@ -748,6 +748,54 @@ "doc": "Runtime and Session Observability", "doc_id": "8e389016-a9c8-4352-9121-72f0e361fdf6", "text": "Family-runtime event rows carry the session's working directory as their workspace attribution so approval deny-kills can resolve cwd-based runtimes." + }, + { + "id": "AC-OBS-007.1", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Report, for a repository named by the operator or discovered from session history, a grade for how legible that repository is to an agent, covering instruction file, test command, build command, lint or format gate, CI configuration and repository-scoped agent skills." + }, + { + "id": "AC-OBS-007.2", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Report, beside that grade and for the same repository, how many sessions ran there in a stated window and how many of them the detectors recorded as stuck." + }, + { + "id": "AC-OBS-007.3", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Report every passing check with the specific path that was read to reach that verdict, and every failing check with the paths that were looked for plus a remediation the operator can act on." + }, + { + "id": "AC-OBS-007.4", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Report a check whose input could not be read as an explicit unknown that does not affect the grade in either direction, rather than as a failure or a pass." + }, + { + "id": "AC-OBS-007.5", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Report a repository with no recorded session history as having no history, distinctly from a repository where sessions ran and none got stuck." + }, + { + "id": "AC-OBS-007.6", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Report, per runtime, which runtimes would find their instructions in the repository, and scope the graded result to the runtime the operator has selected." + }, + { + "id": "AC-OBS-007.7", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Complete without making any network request and without executing any command from the repository under examination." + }, + { + "id": "AC-OBS-007.8", + "doc": "Local Agent Observability", + "doc_id": "d518c6c3-eb50-4b0f-9ed0-59440380b7bf", + "text": "Remain available on every plan, including free, with no entitlement gate." } ] } diff --git a/routes/local_query.py b/routes/local_query.py index f8c5ee7742..7b18f23f40 100644 --- a/routes/local_query.py +++ b/routes/local_query.py @@ -731,6 +731,12 @@ def http_query(): # dashboard. Read by routes/health.py:/api/loop-signals via the daemon # proxy so the dashboard process never opens DuckDB writable. "query_recent_loop_signals", + # WO-5 (repo AI-readiness): sessions joined to their loop signals by the + # directory they ran in, so the Harness tab can put a repo's readiness + # grade next to the stuck rate that repo actually produced. Read by + # routes/readiness.py through this proxy -- the dashboard process never + # opens DuckDB writable. + "query_repo_activity", # Issue #1364 (MOAT 1.b): surface OTel spans we already persist. # Powers /api/spans + the Brain-tab "Spans" table. "query_recent_spans", diff --git a/routes/readiness.py b/routes/readiness.py new file mode 100644 index 0000000000..304104361c --- /dev/null +++ b/routes/readiness.py @@ -0,0 +1,176 @@ +"""``bp_readiness`` — repo AI-readiness. + +Before you blame the agent, look at what you handed it. + +One read-only endpoint scores a code repository on how legible it is to an +agent (instruction file, test command, build command, lint gate, CI config, +skills) and pairs the grade with the stuck-loop and repeated-tool-failure +counts the detectors recorded for sessions that actually ran in that repo. + + GET /api/repo-readiness[?path=][&runtime=][&days=30] + +Free and ungated on purpose: this is the cheapest honest thing ClawMetry can +tell a first-time user about their own repo, and every input is a filesystem +fact plus data already in DuckDB. No entitlement gate, no network call. + +Repo discovery comes from ``sessions.cwd`` (the directory each session +actually ran in) folded up to the nearest git root, so the list is "repos your +agents worked in", never a filesystem crawl of the user's home directory. +""" +from __future__ import annotations + +import logging +import os + +from flask import Blueprint, jsonify, request + +logger = logging.getLogger("clawmetry.routes.readiness") + +bp_readiness = Blueprint("readiness", __name__) + +#: Default correlation window. Long enough that a weekly-cadence repo has +#: history, short enough that a repo you fixed three months ago is not still +#: being judged by its old stuck rate. +_DEFAULT_DAYS = 30 + +#: How many repos the picker offers. The scorer only ever runs on ONE of +#: them per request (scoring is cheap but not free, and a 200-repo machine +#: should not pay for 200 scans to render one card). +_MAX_REPOS = 25 + + +def _repo_activity(days: int) -> list: + """Session/loop-signal rows from DuckDB. ``[]`` when unavailable. + + Routed through the daemon HTTP proxy first: the daemon owns the DuckDB + writer lock, so a direct open from the dashboard process contends with + it. The direct read is a single-process fallback for tests and dev. + """ + try: + from routes.local_query import local_store_via_daemon + rows = local_store_via_daemon( + "query_repo_activity", since_days=days, limit=5000) + if isinstance(rows, list): + return rows + except Exception as exc: + logger.debug("repo-readiness: daemon proxy unavailable: %s", exc) + try: + from clawmetry import local_store + store = local_store.get_store(read_only=True) + if store is None: + return [] + return store.query_repo_activity(since_days=days, limit=5000) or [] + except Exception as exc: + logger.debug("repo-readiness: direct store read failed: %s", exc) + return [] + + +def _fallback_repo() -> "str | None": + """A repo to score when no session recorded a cwd yet. + + Acceptance criterion: the score must render for a repo with no ClawMetry + history at all. On a fresh install ``sessions.cwd`` is empty, so we fall + back to the git root of the directory the dashboard itself is running in. + Returns ``None`` rather than a guess when that is not a repo. + + NEVER on the hosted dashboard. The cloud container runs from ClawMetry's + OWN checkout, so this fallback there would score our source tree and + label it as the user's repo -- a fabricated card about a repo they have + never seen. On cloud the honest answer is "this machine has not uploaded + a scan yet"; the card is served from the daemon's ``repoReadiness`` + snapshot slice, scanned where the agents actually run. + """ + if os.environ.get("CLAWMETRY_CLOUD", "").strip(): + return None + from clawmetry import repo_readiness + try: + cwd = os.getcwd() + except OSError: + return None + return repo_readiness.git_root(cwd) + + +def readiness_payload(path: str = "", runtime: str = "", + days: int = _DEFAULT_DAYS) -> dict: + """Build the endpoint body. Shared with the daemon snapshot builder so + the hosted dashboard renders the same card. Never raises.""" + from clawmetry import repo_readiness + + try: + days = max(0, min(int(days), 365)) + except (TypeError, ValueError): + days = _DEFAULT_DAYS + runtime = (runtime or "").strip().lower() + if runtime in ("", "all", "any"): + runtime = "" + + rows = _repo_activity(days) + repos = repo_readiness.rank_repos(rows, window_days=days, limit=_MAX_REPOS) + + requested = (path or "").strip() + selected_path = "" + if requested: + selected_path = os.path.abspath(os.path.expanduser(requested)) + elif repos: + # The busiest repo that still exists on this machine; a deleted + # checkout keeps its history row but cannot be scored. + live = [r for r in repos if r["exists"]] + selected_path = (live or repos)[0]["path"] + else: + selected_path = _fallback_repo() or "" + + signals = None + for r in repos: + if r["path"] == selected_path: + signals = r["signals"] + break + if signals is None and selected_path: + # A repo with no ClawMetry history: an explicit empty pairing, not a + # fabricated zero stuck rate. + signals = repo_readiness.pair_signals([], window_days=days) + + if not selected_path: + return { + "status": "no_repo", + "detail": "No agent session on this machine has recorded the " + "directory it ran in yet, and the dashboard is not " + "running inside a git repo either.", + "repos": [], "report": None, "window_days": days, + "runtime": runtime or "all", + } + + report = repo_readiness.score_repo( + selected_path, runtime=runtime or None, signals=signals) + return { + "status": "ok", + "repos": repos, + "report": report, + "window_days": days, + "runtime": runtime or "all", + # Local scans honour the runtime switcher, so the card needs no + # "all runtimes" caveat. The DAEMON's snapshot slice sets + # scope="all_runtimes" instead, and the renderer labels that. + "scope": runtime or "all_runtimes", + "discovery": "sessions" if repos else "cwd", + } + + +@bp_readiness.route("/api/repo-readiness", methods=["GET"]) +def http_repo_readiness(): + """Score one repo and list the repos this machine's agents work in. + + Free and ungated: no ``@gate``, by design (WO-5). Never 500s -- an + honest empty state beats a broken card on a first-run dashboard. + """ + try: + return jsonify(readiness_payload( + path=request.args.get("path") or "", + runtime=request.args.get("runtime") or "", + days=request.args.get("days") or _DEFAULT_DAYS, + )) + except Exception as exc: # noqa: BLE001 — never break the tab + logger.warning("repo-readiness failed: %s", exc) + return jsonify({ + "status": "error", "detail": str(exc), "repos": [], + "report": None, "window_days": _DEFAULT_DAYS, "runtime": "all", + }) diff --git a/tests/test_repo_readiness.py b/tests/test_repo_readiness.py new file mode 100644 index 0000000000..157ee05951 --- /dev/null +++ b/tests/test_repo_readiness.py @@ -0,0 +1,490 @@ +"""Guards for clawmetry/repo_readiness.py (WO-5, repo AI-readiness). + +The Trap this work order shipped with: a previous scanner graded settings +nothing in the codebase ever read, so it failed on healthy machines and +taught the operator to ignore the grade. These tests pin the three rules +that prevent a repeat, and they fail on the un-fixed code: + + * every FAIL traces to a filesystem fact this module actually opened + * an unreadable input scores ZERO weight, never a penalty + * nothing here opens a socket or runs a subprocess +""" +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from clawmetry import repo_readiness as rr # noqa: E402 + + +# ── fixtures ─────────────────────────────────────────────────────────────── + +@pytest.fixture() +def bare(tmp_path): + """A directory with nothing in it at all.""" + d = tmp_path / "bare" + d.mkdir() + return str(d) + + +@pytest.fixture() +def furnished(tmp_path): + """A repo that does everything an agent needs.""" + d = tmp_path / "furnished" + (d / ".github" / "workflows").mkdir(parents=True) + (d / ".claude" / "skills" / "deploy").mkdir(parents=True) + (d / ".claude" / "skills" / "deploy" / "SKILL.md").write_text("# deploy\n") + (d / ".github" / "workflows" / "ci.yml").write_text("on: push\n") + (d / "CLAUDE.md").write_text("# how this project works\n") + (d / "package.json").write_text(json.dumps({ + "name": "x", + "scripts": {"test": "jest", "build": "tsc", "lint": "eslint ."}, + })) + return str(d) + + +# ── the grade ────────────────────────────────────────────────────────────── + +def test_furnished_repo_grades_well(furnished): + """Acceptance criteria proven here: + + AC-OBS-007.1 + + a repo that does everything an agent needs grades well. + """ + rep = rr.score_repo(furnished) + assert rep["status"] == "ok" + assert rep["failed"] == 0, [c for c in rep["checks"] if c["status"] == "fail"] + assert rep["score"] in ("A", "B") + + +def test_bare_repo_fails_every_gradeable_check(bare): + """Acceptance criteria proven here: + + AC-OBS-007.1 + + all six graded checks, each answered from the filesystem. + """ + rep = rr.score_repo(bare) + ids = {c["id"]: c["status"] for c in rep["checks"]} + assert ids["instruction_file"] == "fail" + assert ids["test_command"] == "fail" + assert ids["build_command"] == "fail" + assert ids["lint_gate"] == "fail" + assert ids["ci_config"] == "fail" + assert rep["score"] == "F" + + +def test_every_fail_names_the_thing_it_looked_for(bare): + """Acceptance criteria proven here: + + AC-OBS-007.3 + + a FAIL says what was looked for and how to fix it. + """ + for c in rr.score_repo(bare)["checks"]: + if c["status"] == "fail": + assert c["remediation"], c["id"] + assert len(c["detail"]) > 20, c["id"] + + +def test_every_pass_names_the_file_it_read(furnished): + """Acceptance criteria proven here: + + AC-OBS-007.3 + + a PASS carries the path that was read, so a reader can tell a measured result from a shipped constant. + """ + for c in rr.score_repo(furnished)["checks"]: + if c["status"] == "pass" and c["id"] != "instruction_loaded": + assert c["evidence"], c["id"] + # The evidence must be a path that actually exists in the repo. + probe = os.path.join(furnished, c["evidence"].replace("/", os.sep)) + assert os.path.exists(probe), (c["id"], c["evidence"]) + + +# ── ADR-004: unreadable means zero weight, never a penalty ───────────────── + +def test_unknown_checks_carry_zero_weight(furnished): + """Acceptance criteria proven here: + + AC-OBS-007.4 + """ + for c in rr.score_repo(furnished)["checks"]: + if c["status"] == "unknown": + assert c["weight"] == 0, c["id"] + + +def test_check_helper_forces_unknown_to_zero_weight(): + """The rule is enforced in one place so no future check can skip it.""" + c = rr._check("x", "X", rr.UNKNOWN, "d", None, 99) + assert c["weight"] == 0 + + +def test_unreadable_config_scores_unknown_not_fail(tmp_path): + """Acceptance criteria proven here: + + AC-OBS-007.4 + + a package.json we cannot parse must not read as "no test command". This is the exact shape of the bug ADR-004 exists to stop: the file IS there, we simply could not read it, and reporting that as a failure invents a defect. + """ + d = tmp_path / "broken" + d.mkdir() + (d / "package.json").write_text("{ not json at all") + check = rr._check_test_command(str(d)) + assert check["status"] == "unknown" + assert check["weight"] == 0 + + +def test_unknown_moves_the_grade_in_neither_direction(): + """Acceptance criteria proven here: + + AC-OBS-007.4 + + two identical repos, one with an extra unknown, grade the same. + """ + base = [ + rr._check("a", "A", rr.PASS, "d", None, 10), + rr._check("b", "B", rr.FAIL, "d", "fix", 10), + ] + with_unknown = base + [rr._check("c", "C", rr.UNKNOWN, "d", None, 40)] + assert rr._grade(base) == rr._grade(with_unknown) + + +def test_grade_is_not_scored_when_everything_is_unknown(): + checks = [rr._check("a", "A", rr.UNKNOWN, "d", None, 10)] + letter, label, _color, pct = rr._grade(checks) + assert letter == "U" + assert pct == 0.0 + + +def test_instruction_loaded_is_unknown_until_a_runtime_reports_it(furnished): + """We read the file; that proves WE read it, not that the agent did. + + No runtime ClawMetry observes reports its loaded context files today, so + this is an honest zero-weight unknown with the hook in place. + """ + rep = rr.score_repo(furnished) + loaded = [c for c in rep["checks"] if c["id"] == "instruction_loaded"][0] + assert loaded["status"] == "unknown" + assert loaded["weight"] == 0 + + graded = rr.score_repo(furnished, loaded_evidence={ + "observed": True, "runtimes": ["claude_code"], "source": "hook"}) + loaded2 = [c for c in graded["checks"] if c["id"] == "instruction_loaded"][0] + assert loaded2["status"] == "pass" + assert loaded2["weight"] > 0 + + +def test_inherited_default_is_warned_not_passed(tmp_path): + """`cargo test` exists for every Cargo project. That is an inherited + default, which counts as unmeasured, not ready.""" + d = tmp_path / "rust" + d.mkdir() + (d / "Cargo.toml").write_text('[package]\nname = "x"\n') + check = rr._check_test_command(str(d)) + assert check["status"] == "warn" + + +# ── never crashes, never guesses ─────────────────────────────────────────── + +def test_missing_directory_renders_instead_of_raising(): + rep = rr.score_repo("/definitely/not/a/real/path/anywhere") + assert rep["status"] == "not_found" + assert rep["checks"] == [] + + +@pytest.mark.parametrize("junk", [None, "", 0, [], {}]) +def test_never_raises_on_junk_input(junk): + rep = rr.score_repo(junk) + assert isinstance(rep, dict) + assert rep["status"] == "not_found" + + +def test_renders_for_a_repo_with_no_clawmetry_history(furnished): + """Acceptance criteria proven here: + + AC-OBS-007.5 + + the score renders for a repo ClawMetry has never seen, and says it has no history rather than reporting a clean stuck rate. + """ + rep = rr.score_repo(furnished) + assert rep["status"] == "ok" + assert rep["signals"]["has_history"] is False + # Not a fabricated 0%: "nobody worked here" and "nobody got stuck" are + # different facts and must not render the same. + assert rep["signals"]["stuck_rate"] is None + + +def test_no_network_and_no_subprocess(monkeypatch, furnished): + """Acceptance criteria proven here: + + AC-OBS-007.7 + + no network calls, and no subprocess either. We never run the build we are grading. + """ + import socket + import subprocess + + def boom(*a, **k): + raise AssertionError("repo_readiness reached outside the filesystem") + + monkeypatch.setattr(socket, "socket", boom) + monkeypatch.setattr(socket, "create_connection", boom) + monkeypatch.setattr(subprocess, "run", boom) + monkeypatch.setattr(subprocess, "Popen", boom) + monkeypatch.setattr(subprocess, "check_output", boom) + + rep = rr.score_repo(furnished) + assert rep["status"] == "ok" + + +def test_module_source_has_no_subprocess_or_urllib_import(): + """Acceptance criteria proven here: + + AC-OBS-007.7 + + , belt and braces: the guard above only catches what a scan reaches. A future check that shells out to `make test` would be a read-only violation AND a network call; this fails the moment one is added. + """ + src = open(rr.__file__, encoding="utf-8").read() + for banned in ("import subprocess", "import urllib", "import requests", + "import httpx", "os.system", "os.popen"): + assert banned not in src, banned + + +# ── per-runtime honesty ──────────────────────────────────────────────────── + +def test_runtime_scoping_is_honest(furnished): + """Acceptance criteria proven here: + + AC-OBS-007.6 + + a repo legible to Claude Code can be invisible to Cursor. A single node-wide tick would hide that. + """ + claude = rr.score_repo(furnished, runtime="claude_code") + cursor = rr.score_repo(furnished, runtime="cursor") + c_instr = [c for c in claude["checks"] if c["id"] == "instruction_file"][0] + x_instr = [c for c in cursor["checks"] if c["id"] == "instruction_file"][0] + assert c_instr["status"] == "pass" + assert x_instr["status"] == "fail" + assert claude["score_pct"] > cursor["score_pct"] + + +def test_instruction_files_are_derived_from_the_runtime_catalog(): + """The file list must come from runtime_memory, not a second hand-kept + copy that drifts every time a runtime is added.""" + from clawmetry import runtime_memory + + declared = {r["rel"] for r in runtime_memory.project_relative_roots(["memory"])} + assert "CLAUDE.md" in declared + assert "AGENTS.md" in declared + used = {spec["rel"] for spec in rr._instruction_roots(None)} + # Everything the catalog declares is used, minus the roots the AGENT + # writes (its own transcript / scratch memory), which are not evidence + # that a person documented the repo. + assert used == declared - rr._AGENT_WRITTEN_ROOTS + assert declared & rr._AGENT_WRITTEN_ROOTS, ( + "the denylist has drifted from the catalog: none of its entries are " + "declared any more, so it is silently doing nothing") + + +def test_an_agents_own_transcript_is_not_an_instruction_file(tmp_path): + """A repo aider has merely been RUN in is not a repo anyone documented.""" + d = tmp_path / "used" + d.mkdir() + (d / ".aider.input.history").write_text("fix the bug") + (d / ".aider.chat.history.md").write_text("# chat") + check = rr._check_instruction_file( + str(d), None, rr.runtime_coverage(str(d), rr._instruction_roots(None))) + assert check["status"] == "fail" + + +def test_the_suggested_instruction_file_is_the_most_widely_read_one(bare): + """Suggesting `.agent/rules` because it sorts first alphabetically is + advice nobody should follow.""" + check = rr._check_instruction_file( + bare, None, rr.runtime_coverage(bare, rr._instruction_roots(None))) + assert "AGENTS.md" in check["remediation"] + assert check["detail"].index("AGENTS.md") < check["detail"].index(".agent") + + +def test_rel_ranking_is_derived_from_how_many_runtimes_read_the_file(): + roots = [ + {"rel": "zzz.md", "runtime": "a"}, + {"rel": "AGENTS.md", "runtime": "a"}, + {"rel": "AGENTS.md", "runtime": "b"}, + {"rel": "AGENTS.md", "runtime": "c"}, + ] + assert rr._rank_rels(roots) == ["AGENTS.md", "zzz.md"] + + +def test_runtime_coverage_reports_what_it_looked_for(furnished): + """Acceptance criteria proven here: + + AC-OBS-007.6 + + per runtime, what was found and where we looked. + """ + cov = rr.runtime_coverage(furnished) + by_id = {r["runtime"]: r for r in cov} + assert by_id["claude_code"]["has_instructions"] is True + assert "CLAUDE.md" in by_id["claude_code"]["files"] + # Even a runtime that found nothing says where it looked. + for row in cov: + assert row["looked_for"], row["runtime"] + + +# ── the pairing ──────────────────────────────────────────────────────────── + +def _row(sid, cwd, signature=None, details=None, ts="2026-08-20T10:00:00"): + return {"session_id": sid, "cwd": cwd, "signature": signature, + "details": details, "last_active_at": ts} + + +def test_pair_signals_counts_sessions_and_incidents(): + """Acceptance criteria proven here: + + AC-OBS-007.2 + + sessions in the window, and how many got stuck. + """ + rows = [ + _row("s1", "/r"), + _row("s2", "/r", "daemon_detect_stuck_loop", {"kind": "stuck_loop"}), + _row("s3", "/r", "daemon_detect_repeated_tool_failure", + {"kind": "repeated_tool_failure"}), + ] + sig = rr.pair_signals(rows, window_days=30) + assert sig["sessions"] == 3 + assert sig["stuck_sessions"] == 2 + assert sig["stuck_rate"] == pytest.approx(66.7) + assert sig["incidents"]["stuck_loop"] == 1 + assert sig["incidents"]["repeated_tool_failure"] == 1 + + +def test_one_session_with_two_signals_counts_once_as_stuck(): + rows = [ + _row("s1", "/r", "daemon_detect_stuck_loop", {"kind": "stuck_loop"}), + _row("s1", "/r", "daemon_detect_no_progress", {"kind": "no_progress"}), + ] + sig = rr.pair_signals(rows, window_days=30) + assert sig["sessions"] == 1 + assert sig["stuck_sessions"] == 1 + assert sig["incidents"]["stuck_loop"] == 1 + assert sig["incidents"]["no_progress"] == 1 + + +def test_no_sessions_means_no_rate_not_a_zero_rate(): + """Acceptance criteria proven here: + + AC-OBS-007.5 + """ + sig = rr.pair_signals([], window_days=30) + assert sig["stuck_rate"] is None + assert sig["has_history"] is False + + +def test_signal_kind_mirrors_the_daemon_mapping(): + """If sync.py learns a new detector signature, this catches the drift.""" + from clawmetry import sync + + for signature, kind in sync._LOOPS_KIND_BY_SIGNATURE.items(): + assert rr.signal_kind(signature, None) == kind + + +def test_unrecognised_signature_is_still_a_loop(): + """The proxy LoopDetector writes a request hash, not a named signature. + Dropping it would under-report the stuck rate.""" + assert rr.signal_kind("a1b2c3d4", None) == "stuck_loop" + assert rr.signal_kind("", None) is None + assert rr.signal_kind(None, None) is None + + +def test_details_json_string_is_parsed(): + """DuckDB hands details back as a JSON string on some paths.""" + assert rr.signal_kind("x", json.dumps({"kind": "no_progress"})) == "no_progress" + + +# ── repo discovery ───────────────────────────────────────────────────────── + +def test_sessions_are_grouped_by_git_root(tmp_path): + repo = tmp_path / "proj" + (repo / ".git").mkdir(parents=True) + deep = repo / "src" / "pkg" + deep.mkdir(parents=True) + rows = [_row("s1", str(repo)), _row("s2", str(deep))] + grouped = rr.group_by_repo(rows) + assert list(grouped) == [str(repo)] + assert len(grouped[str(repo)]) == 2 + + +def test_a_directory_outside_any_repo_stands_on_its_own(tmp_path): + loose = tmp_path / "loose" + loose.mkdir() + grouped = rr.group_by_repo([_row("s1", str(loose))]) + assert str(loose) in grouped + + +def test_rank_repos_puts_the_busiest_first(tmp_path): + a, b = tmp_path / "a", tmp_path / "b" + a.mkdir() + b.mkdir() + rows = [_row("s1", str(a)), _row("s2", str(a)), _row("s3", str(b))] + ranked = rr.rank_repos(rows, window_days=30) + assert ranked[0]["path"] == str(a) + assert ranked[0]["signals"]["sessions"] == 2 + + +def test_rows_without_a_cwd_are_dropped_not_guessed(): + """A session with no recorded directory cannot be attributed to a repo, + and guessing one would fabricate the very correlation this shows.""" + assert rr.group_by_repo([_row("s1", None), _row("s2", "")]) == {} + + +def test_git_root_finds_a_worktree(tmp_path): + """In a worktree or submodule, .git is a FILE, not a directory.""" + wt = tmp_path / "wt" + wt.mkdir() + (wt / ".git").write_text("gitdir: /elsewhere/.git/worktrees/wt\n") + (wt / "sub").mkdir() + assert rr.git_root(str(wt / "sub")) == str(wt) + # A cwd whose subdirectory has since been deleted still maps to its repo: + # the walk is over path segments, not over what survives on disk. + assert rr.git_root(str(wt / "gone")) == str(wt) + assert rr.git_root("") is None + assert rr.git_root(None) is None + + +# ── probe helpers ────────────────────────────────────────────────────────── + +def test_makefile_recipe_lines_are_not_targets(): + """A tab-indented recipe line containing a colon is not a target. Reading + it as one would pass a repo with no test target at all.""" + mk = "build:\n\techo not:a:target\n\ncheck-deps: build\n\techo x\n" + assert rr._make_targets(mk) == {"build", "check-deps"} + + +def test_makefile_variable_assignment_is_not_a_target(): + assert "PY" not in rr._make_targets("PY := python3\ntest:\n\tpytest\n") + + +def test_phony_is_not_a_target(): + assert rr._make_targets(".PHONY: test\ntest:\n\tpytest\n") == {"test"} + + +def test_toml_sections_is_a_literal_scan(): + text = "[build-system]\nrequires = []\n[tool.ruff]\nline-length = 100\n" + assert rr._toml_sections(text) == {"build-system", "tool.ruff"} + + +def test_case_insensitive_filesystem_reports_one_makefile(tmp_path): + """On macOS and Windows, Makefile and makefile are the SAME file. Probing + both must not report it twice.""" + d = tmp_path / "mk" + d.mkdir() + (d / "Makefile").write_text("test:\n\tpytest\n") + detail = rr._check_test_command(str(d))["detail"] + assert detail.lower().count("makefile") == 1 diff --git a/tests/test_repo_readiness_route.py b/tests/test_repo_readiness_route.py new file mode 100644 index 0000000000..de8ac8d99e --- /dev/null +++ b/tests/test_repo_readiness_route.py @@ -0,0 +1,250 @@ +"""Route + snapshot-slice guards for repo AI-readiness (WO-5). + +Covers the two things a unit test of the scorer cannot: that the endpoint is +free and ungated, and that the hosted dashboard gets the same card from the +snapshot instead of a blank one (FLYWHEEL section 0a.1, cloud parity). +""" +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from clawmetry import repo_readiness as rr # noqa: E402 +import routes.readiness as readiness # noqa: E402 + + +@pytest.fixture() +def repo(tmp_path): + d = tmp_path / "proj" + (d / ".git").mkdir(parents=True) + (d / "CLAUDE.md").write_text("# project\n") + (d / "Makefile").write_text(".PHONY: test lint\ntest:\n\tpytest\nlint:\n\truff check .\n") + return str(d) + + +def _row(sid, cwd, signature=None, details=None, + ts="2026-08-20T10:00:00+00:00"): + return {"session_id": sid, "cwd": cwd, "signature": signature, + "details": details, "last_active_at": ts} + + +# ── the endpoint ─────────────────────────────────────────────────────────── + +def test_payload_scores_the_requested_path(monkeypatch, repo): + monkeypatch.setattr(readiness, "_repo_activity", lambda days: []) + body = readiness.readiness_payload(path=repo) + assert body["status"] == "ok" + assert body["report"]["path"] == repo + assert body["report"]["signals"]["has_history"] is False + + +def test_payload_picks_the_busiest_live_repo(monkeypatch, repo, tmp_path): + """Acceptance criteria proven here: + + AC-OBS-007.1 + AC-OBS-007.2 + + a repo discovered from session history, with the session and stuck counts for that same repo beside its grade. + """ + quiet = tmp_path / "quiet" + quiet.mkdir() + rows = [_row("s1", repo), _row("s2", repo), _row("s3", str(quiet))] + monkeypatch.setattr(readiness, "_repo_activity", lambda days: rows) + body = readiness.readiness_payload() + assert body["report"]["path"] == repo + assert body["report"]["signals"]["sessions"] == 2 + assert [r["path"] for r in body["repos"]][0] == repo + + +def test_payload_skips_a_deleted_checkout_when_choosing(monkeypatch, repo): + """A deleted checkout keeps its history row, but there is nothing left to + read, so it must not be the repo we open the card on.""" + rows = [_row("s1", "/gone/a"), _row("s2", "/gone/a"), _row("s3", repo)] + monkeypatch.setattr(readiness, "_repo_activity", lambda days: rows) + body = readiness.readiness_payload() + assert body["report"]["path"] == repo + gone = [r for r in body["repos"] if r["path"] == "/gone/a"][0] + assert gone["exists"] is False + assert gone["signals"]["sessions"] == 2 + + +def test_payload_is_honest_when_there_is_nothing_to_score(monkeypatch): + monkeypatch.setattr(readiness, "_repo_activity", lambda days: []) + monkeypatch.setattr(readiness, "_fallback_repo", lambda: None) + body = readiness.readiness_payload() + assert body["status"] == "no_repo" + assert body["report"] is None + assert body["repos"] == [] + + +@pytest.fixture() +def client(): + """Blueprints are wired in dashboard.main(); register ours for the test + client the same way the other route tests do.""" + import dashboard as _d + from routes.readiness import bp_readiness + + if "readiness" not in _d.app.blueprints: + _d.app.register_blueprint(bp_readiness) + _d.app.config["TESTING"] = True + return _d.app.test_client() + + +def test_endpoint_returns_an_honest_body_when_the_store_is_down( + monkeypatch, client, repo): + """A locked DuckDB must not turn into a 500 on a first-run dashboard.""" + def boom(days): + raise RuntimeError("duckdb is locked") + monkeypatch.setattr(readiness, "_repo_activity", boom) + resp = client.get("/api/repo-readiness?path=" + repo) + assert resp.status_code == 200 + body = resp.get_json() + assert body["status"] == "error" + assert body["report"] is None + + +def test_endpoint_serves_a_scored_report(monkeypatch, client, repo): + monkeypatch.setattr(readiness, "_repo_activity", lambda days: []) + resp = client.get("/api/repo-readiness?path=" + repo) + assert resp.status_code == 200 + body = resp.get_json() + assert body["status"] == "ok" + assert body["report"]["path"] == repo + assert body["report"]["score"] in list("ABCDF") + + +def test_the_hosted_dashboard_never_scores_its_own_checkout(monkeypatch): + """The cloud container runs from ClawMetry's own source tree. Falling back + to its working directory there would render a card about OUR repo and + label it as the user's.""" + monkeypatch.setattr(readiness, "_repo_activity", lambda days: []) + monkeypatch.setenv("CLAWMETRY_CLOUD", "1") + body = readiness.readiness_payload() + assert body["status"] == "no_repo" + assert body["report"] is None + monkeypatch.delenv("CLAWMETRY_CLOUD") + # ...and off cloud the fallback still works, so a first-run local install + # sees its own repo scored. + assert readiness._fallback_repo() is not None + + +def test_endpoint_is_free_and_ungated(): + """Acceptance criteria proven here: + + AC-OBS-007.8 + + free and ungated. A lead magnet, not a paid surface. + """ + src = open(readiness.__file__, encoding="utf-8").read() + assert "@gate(" not in src + assert "allows_feature" not in src + + +def test_endpoint_makes_no_network_calls(): + src = open(readiness.__file__, encoding="utf-8").read() + for banned in ("import requests", "import httpx", "urlopen", "subprocess"): + assert banned not in src, banned + + +def test_window_is_clamped(monkeypatch, repo): + monkeypatch.setattr(readiness, "_repo_activity", lambda days: []) + assert readiness.readiness_payload(path=repo, days=99999)["window_days"] == 365 + assert readiness.readiness_payload(path=repo, days="junk")["window_days"] == 30 + assert readiness.readiness_payload(path=repo, days=-5)["window_days"] == 0 + + +def test_a_local_scan_does_not_claim_all_runtimes(monkeypatch, repo): + """The local endpoint re-scans per runtime, so the card must not show the + hosted "scored against every runtime" caveat when a filter is on.""" + monkeypatch.setattr(readiness, "_repo_activity", lambda days: []) + assert readiness.readiness_payload( + path=repo, runtime="cursor")["scope"] == "cursor" + assert readiness.readiness_payload(path=repo)["scope"] == "all_runtimes" + + +def test_runtime_filter_reaches_the_score(monkeypatch, repo): + monkeypatch.setattr(readiness, "_repo_activity", lambda days: []) + claude = readiness.readiness_payload(path=repo, runtime="claude_code") + cursor = readiness.readiness_payload(path=repo, runtime="cursor") + assert claude["report"]["score_pct"] > cursor["report"]["score_pct"] + assert readiness.readiness_payload(path=repo, runtime="all")["runtime"] == "all" + + +# ── cloud parity: the daemon ships the finished card ─────────────────────── + +class _FakeStore: + def __init__(self, rows): + self._rows = rows + + def query_repo_activity(self, **kwargs): + return self._rows + + +def test_snapshot_slice_carries_a_scored_report(repo): + from clawmetry import sync + + slice_ = sync._build_repo_readiness_slice(_FakeStore([_row("s1", repo)])) + assert slice_["repos"], "the daemon must score the repo, not just list it" + entry = slice_["repos"][0] + assert entry["path"] == repo + assert entry["report"]["score"] in list("ABCDF") + assert entry["signals"]["sessions"] == 1 + + +def test_snapshot_slice_labels_itself_all_runtimes(repo): + """The daemon cannot know which runtime the hosted viewer selected, so + the slice must say so rather than letting the cloud pass node-wide data + off as runtime-scoped.""" + from clawmetry import sync + + slice_ = sync._build_repo_readiness_slice(_FakeStore([_row("s1", repo)])) + assert slice_["scope"] == "all_runtimes" + + +def test_snapshot_slice_leaves_a_deleted_checkout_unscored(): + from clawmetry import sync + + slice_ = sync._build_repo_readiness_slice(_FakeStore([_row("s1", "/gone")])) + assert slice_["repos"][0]["report"] is None + assert slice_["repos"][0]["signals"]["sessions"] == 1 + + +def test_snapshot_slice_is_empty_not_broken_when_the_store_fails(): + from clawmetry import sync + + class Broken: + def query_repo_activity(self, **kwargs): + raise RuntimeError("no") + + assert sync._build_repo_readiness_slice(Broken()) == {} + + +def test_snapshot_slice_is_capped(monkeypatch, tmp_path): + from clawmetry import sync + + rows = [] + for i in range(20): + d = tmp_path / ("r%d" % i) + d.mkdir() + rows.append(_row("s%d" % i, str(d))) + slice_ = sync._build_repo_readiness_slice(_FakeStore(rows)) + assert len(slice_["repos"]) <= sync._READINESS_SLICE_MAX + + +# ── the daemon proxy must know the method ────────────────────────────────── + +def test_query_repo_activity_is_allowlisted(): + """A store method the dashboard reaches through the daemon proxy is a + 400 until it is named in the allowlist.""" + from routes.local_query import _DAEMON_METHODS + + assert "query_repo_activity" in _DAEMON_METHODS + + +def test_store_declares_query_repo_activity(): + from clawmetry.local_store import LocalStore + + assert callable(getattr(LocalStore, "query_repo_activity", None))