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.
';
+
+ // 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=