From 256ab3b8c5f61dcf548911627bed23cd06a4d75c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 06:37:29 +0000 Subject: [PATCH 1/7] feat(dives): add GET /api/dives/questions gallery endpoint (DIVES-5) Add the suggested-questions gallery for the Dives UI: - routes/dives.py: add SUGGESTED_QUESTIONS constant (15 curated entries covering cost, activity, sessions, crons, system, memory categories) and the GET /api/dives/questions endpoint that returns them as JSON. - tests/test_dives_questions.py: new regression suite that pins entry count, validates schema per entry (question/chart_type/category), asserts no duplicates, enforces known chart_type and category values, and checks minimum question length. Flask is mocked at import time so the test runs without Flask installed. Closes part of https://github.com/vivekchand/clawmetry/issues/999 Sub-issue: https://github.com/vivekchand/clawmetry/issues/1003 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC4sb3JPNiiF3gBunvJtnh --- routes/dives.py | 52 ++++++++++++++++ tests/test_dives_questions.py | 114 ++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 tests/test_dives_questions.py diff --git a/routes/dives.py b/routes/dives.py index ca44dc80dd..66ea2b542a 100644 --- a/routes/dives.py +++ b/routes/dives.py @@ -384,3 +384,55 @@ def api_dives_delete(slug: str): except OSError as e: return jsonify({"error": f"Delete failed: {e}"}), 500 return jsonify({"deleted": safe}) + + +# ── Suggested-questions gallery (DIVES-5) ───────────────────────────────────── + +#: Curated starter questions for the Dives UI. Each entry has ``question`` +#: (display text), ``chart_type`` (Chart.js type) and ``category`` (for +#: UI grouping). The test suite pins the count — update ``_EXPECTED_COUNT`` +#: in ``tests/test_dives_questions.py`` whenever you add or remove entries. +SUGGESTED_QUESTIONS: tuple[dict, ...] = ( + # ── Cost & spend ────────────────────────────────────────────────────────── + {"question": "Show total cost per agent runtime over the last 7 days", + "chart_type": "bar", "category": "cost"}, + {"question": "What is my total LLM spend per day for the past 30 days?", + "chart_type": "line", "category": "cost"}, + {"question": "Which sessions cost the most? Show the top 10 by total cost.", + "chart_type": "bar", "category": "cost"}, + {"question": "What fraction of my total spend goes to each LLM model?", + "chart_type": "doughnut", "category": "cost"}, + # ── Usage & activity ────────────────────────────────────────────────────── + {"question": "How many sessions have I started per day this month?", + "chart_type": "line", "category": "activity"}, + {"question": "Show me total token consumption per agent runtime", + "chart_type": "bar", "category": "activity"}, + {"question": "What are the most common event types across all agents?", + "chart_type": "doughnut", "category": "activity"}, + {"question": "How many events were recorded per hour today?", + "chart_type": "bar", "category": "activity"}, + # ── Sessions ────────────────────────────────────────────────────────────── + {"question": "Show average message count per session, grouped by agent runtime", + "chart_type": "bar", "category": "sessions"}, + {"question": "How many sub-agents were spawned per session this week?", + "chart_type": "bar", "category": "sessions"}, + # ── Crons & ops ─────────────────────────────────────────────────────────── + {"question": "How many cron jobs are registered per agent runtime?", + "chart_type": "doughnut", "category": "crons"}, + {"question": "Show daily cron run counts over the last 14 days", + "chart_type": "line", "category": "crons"}, + # ── System health ───────────────────────────────────────────────────────── + {"question": "Plot memory usage percentage over the last 24 hours", + "chart_type": "line", "category": "system"}, + {"question": "Show CPU usage trend from system snapshots this week", + "chart_type": "line", "category": "system"}, + # ── Memory & context ────────────────────────────────────────────────────── + {"question": "How many memory blobs are stored per agent runtime?", + "chart_type": "bar", "category": "memory"}, +) + + +@bp_dives.route("/api/dives/questions") +def api_dives_questions(): + """GET → {questions: [{question, chart_type, category}, ...]}""" + return jsonify({"questions": [dict(q) for q in SUGGESTED_QUESTIONS]}) diff --git a/tests/test_dives_questions.py b/tests/test_dives_questions.py new file mode 100644 index 0000000000..dc40b2c4ff --- /dev/null +++ b/tests/test_dives_questions.py @@ -0,0 +1,114 @@ +"""Tests for DIVES-5: suggested-questions gallery (routes.dives.SUGGESTED_QUESTIONS). + +Regression guards: +- Fixed schema per entry (question, chart_type, category). +- No duplicate question text. +- All chart_type values are known Chart.js types. +- All category values are in the allowed set. +- Entry count pinned — new entries require an intentional bump here. + +Sub-issue: https://github.com/vivekchand/clawmetry/issues/1003 +Closes: part of https://github.com/vivekchand/clawmetry/issues/999 +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +# routes/dives.py imports Flask at module level; mock it so this test runs +# even when Flask is not installed (pure data validation, no HTTP needed). +if "flask" not in sys.modules: + _flask_mock = MagicMock() + sys.modules["flask"] = _flask_mock + sys.modules["flask"].Blueprint = MagicMock(return_value=MagicMock()) + sys.modules["flask"].jsonify = MagicMock() + sys.modules["flask"].request = MagicMock() + +from routes.dives import SUGGESTED_QUESTIONS # noqa: E402 + +_KNOWN_CHART_TYPES = frozenset({"bar", "line", "doughnut", "pie", "scatter", "bubble", "radar"}) +_KNOWN_CATEGORIES = frozenset({"cost", "activity", "sessions", "crons", "system", "memory", "channels"}) + +# One-way ratchet — update intentionally when adding or removing entries. +_EXPECTED_COUNT = 15 + + +# --------------------------------------------------------------------------- +# Whole-list invariants +# --------------------------------------------------------------------------- + + +def test_non_empty(): + assert len(SUGGESTED_QUESTIONS) > 0 + + +def test_count_pinned(): + assert len(SUGGESTED_QUESTIONS) == _EXPECTED_COUNT, ( + f"SUGGESTED_QUESTIONS has {len(SUGGESTED_QUESTIONS)} entries, expected " + f"{_EXPECTED_COUNT}. Update _EXPECTED_COUNT in this file if intentional." + ) + + +def test_no_duplicate_question_text(): + texts = [q["question"] for q in SUGGESTED_QUESTIONS] + assert len(texts) == len(set(texts)), "Duplicate question text found" + + +def test_multiple_chart_types_present(): + types = {q["chart_type"] for q in SUGGESTED_QUESTIONS} + assert len(types) >= 2, "Gallery should use at least two chart types" + + +def test_cost_and_activity_categories_present(): + cats = {q["category"] for q in SUGGESTED_QUESTIONS} + assert "cost" in cats, "No cost-category questions in gallery" + assert "activity" in cats, "No activity-category questions in gallery" + + +# --------------------------------------------------------------------------- +# Per-entry invariants (parametrised so failures name the offending entry) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("entry,idx", [(q, i) for i, q in enumerate(SUGGESTED_QUESTIONS)]) +def test_entry_is_dict(entry, idx): + assert isinstance(entry, dict), f"Entry {idx} is not a dict" + + +@pytest.mark.parametrize("entry,idx", [(q, i) for i, q in enumerate(SUGGESTED_QUESTIONS)]) +def test_entry_has_question(entry, idx): + assert "question" in entry, f"Entry {idx} missing 'question'" + assert isinstance(entry["question"], str) and entry["question"].strip(), ( + f"Entry {idx}: 'question' must be a non-empty string" + ) + + +@pytest.mark.parametrize("entry,idx", [(q, i) for i, q in enumerate(SUGGESTED_QUESTIONS)]) +def test_entry_question_min_length(entry, idx): + assert len(entry["question"].strip()) >= 10, ( + f"Entry {idx} question too short: {entry['question']!r}" + ) + + +@pytest.mark.parametrize("entry,idx", [(q, i) for i, q in enumerate(SUGGESTED_QUESTIONS)]) +def test_entry_chart_type_valid(entry, idx): + assert "chart_type" in entry, f"Entry {idx} missing 'chart_type'" + assert entry["chart_type"] in _KNOWN_CHART_TYPES, ( + f"Entry {idx} unknown chart_type {entry['chart_type']!r}; " + f"valid: {sorted(_KNOWN_CHART_TYPES)}" + ) + + +@pytest.mark.parametrize("entry,idx", [(q, i) for i, q in enumerate(SUGGESTED_QUESTIONS)]) +def test_entry_category_valid(entry, idx): + assert "category" in entry, f"Entry {idx} missing 'category'" + assert entry["category"] in _KNOWN_CATEGORIES, ( + f"Entry {idx} unknown category {entry['category']!r}; " + f"valid: {sorted(_KNOWN_CATEGORIES)}" + ) From 7adf20814267b3c8c7711f19573b7ec125ed5631 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 06:48:41 +0000 Subject: [PATCH 2/7] refactor(dives): register /api/dives/questions before wildcard routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static routes should be declared before dynamic wildcard routes so the route table reads from most-specific to least-specific. Move the SUGGESTED_QUESTIONS constant and GET /api/dives/questions endpoint to appear before GET/DELETE /api/dives/ — no behaviour change, Flask already prioritises static segments, but the ordering now makes the intent unambiguous. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC4sb3JPNiiF3gBunvJtnh --- routes/dives.py | 76 ++++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/routes/dives.py b/routes/dives.py index 66ea2b542a..20e02a2bf0 100644 --- a/routes/dives.py +++ b/routes/dives.py @@ -348,44 +348,6 @@ def api_dives_list(): return jsonify({"dives": _list_dives()}) -@bp_dives.route("/api/dives/") -def api_dives_get(slug: str): - """GET → dive record + re-run rows against live data.""" - record = _read_dive(slug) - if record is None: - return jsonify({"error": "Not found."}), 404 - - sql = record.get("sql", "") - rows: list[dict] = [] - run_error: str | None = None - if sql: - try: - store = _get_store() - rows, run_error = _execute(sql, store) - except Exception as e: - run_error = str(e)[:200] - - body = dict(record) - body["rows"] = rows - if run_error: - body["run_error"] = run_error - return jsonify(body) - - -@bp_dives.route("/api/dives/", methods=["DELETE"]) -def api_dives_delete(slug: str): - """DELETE → {deleted: slug}""" - safe = _safe_slug(slug) - path = os.path.join(_dives_dir(), safe + ".json") - if not os.path.isfile(path): - return jsonify({"error": "Not found."}), 404 - try: - os.remove(path) - except OSError as e: - return jsonify({"error": f"Delete failed: {e}"}), 500 - return jsonify({"deleted": safe}) - - # ── Suggested-questions gallery (DIVES-5) ───────────────────────────────────── #: Curated starter questions for the Dives UI. Each entry has ``question`` @@ -436,3 +398,41 @@ def api_dives_delete(slug: str): def api_dives_questions(): """GET → {questions: [{question, chart_type, category}, ...]}""" return jsonify({"questions": [dict(q) for q in SUGGESTED_QUESTIONS]}) + + +@bp_dives.route("/api/dives/") +def api_dives_get(slug: str): + """GET → dive record + re-run rows against live data.""" + record = _read_dive(slug) + if record is None: + return jsonify({"error": "Not found."}), 404 + + sql = record.get("sql", "") + rows: list[dict] = [] + run_error: str | None = None + if sql: + try: + store = _get_store() + rows, run_error = _execute(sql, store) + except Exception as e: + run_error = str(e)[:200] + + body = dict(record) + body["rows"] = rows + if run_error: + body["run_error"] = run_error + return jsonify(body) + + +@bp_dives.route("/api/dives/", methods=["DELETE"]) +def api_dives_delete(slug: str): + """DELETE → {deleted: slug}""" + safe = _safe_slug(slug) + path = os.path.join(_dives_dir(), safe + ".json") + if not os.path.isfile(path): + return jsonify({"error": "Not found."}), 404 + try: + os.remove(path) + except OSError as e: + return jsonify({"error": f"Delete failed: {e}"}), 500 + return jsonify({"deleted": safe}) From ecf746dd3d8415cf7e1859be64d351e5661c75da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 10:02:11 +0000 Subject: [PATCH 3/7] fix(dives): address drift-bot findings on DIVES-5 gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: rename SUGGESTED_QUESTIONS → DIVES_GALLERY_QUESTIONS in routes/dives.py to avoid naming collision with the identically named (but schema-incompatible) constant in clawmetry/dives_prompt.py. Add a docstring cross-reference explaining the distinction. Update the test import alias accordingly. Finding 3: remove "channels" from _KNOWN_CATEGORIES in tests/test_dives_questions.py — no gallery entry uses that category so the set was more permissive than necessary. Finding 2 (doughnut unsupported): not a real issue — addressed via PR comment. SUPPORTED_CHART_TYPES in dives_prompt.py constrains LLM output for freeform queries; it does not enumerate frontend rendering capability. Chart.js natively supports "doughnut". Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC4sb3JPNiiF3gBunvJtnh --- routes/dives.py | 15 +++++++++------ tests/test_dives_questions.py | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/routes/dives.py b/routes/dives.py index 20e02a2bf0..67f15f9e92 100644 --- a/routes/dives.py +++ b/routes/dives.py @@ -350,11 +350,14 @@ def api_dives_list(): # ── Suggested-questions gallery (DIVES-5) ───────────────────────────────────── -#: Curated starter questions for the Dives UI. Each entry has ``question`` -#: (display text), ``chart_type`` (Chart.js type) and ``category`` (for -#: UI grouping). The test suite pins the count — update ``_EXPECTED_COUNT`` -#: in ``tests/test_dives_questions.py`` whenever you add or remove entries. -SUGGESTED_QUESTIONS: tuple[dict, ...] = ( +#: Curated starter questions for the Dives UI gallery. Each entry has +#: ``question`` (display text), ``chart_type`` (Chart.js type) and +#: ``category`` (for UI grouping). Distinct from +#: ``clawmetry.dives_prompt.SUGGESTED_QUESTIONS``, which carries pre-validated +#: SQL answer objects for the LLM prompt builder. The test suite pins the +#: count — update ``_EXPECTED_COUNT`` in ``tests/test_dives_questions.py`` +#: whenever you add or remove entries. +DIVES_GALLERY_QUESTIONS: tuple[dict, ...] = ( # ── Cost & spend ────────────────────────────────────────────────────────── {"question": "Show total cost per agent runtime over the last 7 days", "chart_type": "bar", "category": "cost"}, @@ -397,7 +400,7 @@ def api_dives_list(): @bp_dives.route("/api/dives/questions") def api_dives_questions(): """GET → {questions: [{question, chart_type, category}, ...]}""" - return jsonify({"questions": [dict(q) for q in SUGGESTED_QUESTIONS]}) + return jsonify({"questions": [dict(q) for q in DIVES_GALLERY_QUESTIONS]}) @bp_dives.route("/api/dives/") diff --git a/tests/test_dives_questions.py b/tests/test_dives_questions.py index dc40b2c4ff..c5bb901b4e 100644 --- a/tests/test_dives_questions.py +++ b/tests/test_dives_questions.py @@ -30,10 +30,10 @@ sys.modules["flask"].jsonify = MagicMock() sys.modules["flask"].request = MagicMock() -from routes.dives import SUGGESTED_QUESTIONS # noqa: E402 +from routes.dives import DIVES_GALLERY_QUESTIONS as SUGGESTED_QUESTIONS # noqa: E402 _KNOWN_CHART_TYPES = frozenset({"bar", "line", "doughnut", "pie", "scatter", "bubble", "radar"}) -_KNOWN_CATEGORIES = frozenset({"cost", "activity", "sessions", "crons", "system", "memory", "channels"}) +_KNOWN_CATEGORIES = frozenset({"cost", "activity", "sessions", "crons", "system", "memory"}) # One-way ratchet — update intentionally when adding or removing entries. _EXPECTED_COUNT = 15 From 902e31f422e16c5959f14429771bf5a1c7f24fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 10:05:50 +0000 Subject: [PATCH 4/7] fix(dives): replace doughnut with pie to align with SUPPORTED_CHART_TYPES The drift bot treats clawmetry/dives_prompt.py's SUPPORTED_CHART_TYPES {"bar","line","pie","table","number"} as the authoritative list of renderable chart types. "doughnut" is not in that set, so replace all three doughnut entries in DIVES_GALLERY_QUESTIONS with "pie" (which renders the same proportion data). Remove "doughnut" from the test allowlist accordingly. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC4sb3JPNiiF3gBunvJtnh --- routes/dives.py | 6 +++--- tests/test_dives_questions.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/dives.py b/routes/dives.py index 67f15f9e92..f83b7a6099 100644 --- a/routes/dives.py +++ b/routes/dives.py @@ -366,14 +366,14 @@ def api_dives_list(): {"question": "Which sessions cost the most? Show the top 10 by total cost.", "chart_type": "bar", "category": "cost"}, {"question": "What fraction of my total spend goes to each LLM model?", - "chart_type": "doughnut", "category": "cost"}, + "chart_type": "pie", "category": "cost"}, # ── Usage & activity ────────────────────────────────────────────────────── {"question": "How many sessions have I started per day this month?", "chart_type": "line", "category": "activity"}, {"question": "Show me total token consumption per agent runtime", "chart_type": "bar", "category": "activity"}, {"question": "What are the most common event types across all agents?", - "chart_type": "doughnut", "category": "activity"}, + "chart_type": "pie", "category": "activity"}, {"question": "How many events were recorded per hour today?", "chart_type": "bar", "category": "activity"}, # ── Sessions ────────────────────────────────────────────────────────────── @@ -383,7 +383,7 @@ def api_dives_list(): "chart_type": "bar", "category": "sessions"}, # ── Crons & ops ─────────────────────────────────────────────────────────── {"question": "How many cron jobs are registered per agent runtime?", - "chart_type": "doughnut", "category": "crons"}, + "chart_type": "pie", "category": "crons"}, {"question": "Show daily cron run counts over the last 14 days", "chart_type": "line", "category": "crons"}, # ── System health ───────────────────────────────────────────────────────── diff --git a/tests/test_dives_questions.py b/tests/test_dives_questions.py index c5bb901b4e..1e4234e7c3 100644 --- a/tests/test_dives_questions.py +++ b/tests/test_dives_questions.py @@ -32,7 +32,7 @@ from routes.dives import DIVES_GALLERY_QUESTIONS as SUGGESTED_QUESTIONS # noqa: E402 -_KNOWN_CHART_TYPES = frozenset({"bar", "line", "doughnut", "pie", "scatter", "bubble", "radar"}) +_KNOWN_CHART_TYPES = frozenset({"bar", "line", "pie", "scatter", "bubble", "radar"}) _KNOWN_CATEGORIES = frozenset({"cost", "activity", "sessions", "crons", "system", "memory"}) # One-way ratchet — update intentionally when adding or removing entries. From 3b54490748426860a3b7f9d7c1152f53a534ce82 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 10:08:00 +0000 Subject: [PATCH 5/7] fix(dives): align _KNOWN_CHART_TYPES with SUPPORTED_CHART_TYPES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift bot flagged that the test allowlist accepted chart types not in clawmetry/dives_prompt.py's SUPPORTED_CHART_TYPES. Narrow the set to {"bar","line","pie","table","number"} — the single source of truth for what the frontend can render — dropping the unused scatter/bubble/radar entries. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC4sb3JPNiiF3gBunvJtnh --- tests/test_dives_questions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dives_questions.py b/tests/test_dives_questions.py index 1e4234e7c3..2600ed2d04 100644 --- a/tests/test_dives_questions.py +++ b/tests/test_dives_questions.py @@ -32,7 +32,7 @@ from routes.dives import DIVES_GALLERY_QUESTIONS as SUGGESTED_QUESTIONS # noqa: E402 -_KNOWN_CHART_TYPES = frozenset({"bar", "line", "pie", "scatter", "bubble", "radar"}) +_KNOWN_CHART_TYPES = frozenset({"bar", "line", "pie", "table", "number"}) _KNOWN_CATEGORIES = frozenset({"cost", "activity", "sessions", "crons", "system", "memory"}) # One-way ratchet — update intentionally when adding or removing entries. From a40604b26f4dcc0eb6f5fb7472486f508b0f4822 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 10:10:06 +0000 Subject: [PATCH 6/7] fix(dives): update test docstring to reference DIVES_GALLERY_QUESTIONS Stale reference to SUGGESTED_QUESTIONS after rename in ecf746d. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PC4sb3JPNiiF3gBunvJtnh --- tests/test_dives_questions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dives_questions.py b/tests/test_dives_questions.py index 2600ed2d04..d398a0f01e 100644 --- a/tests/test_dives_questions.py +++ b/tests/test_dives_questions.py @@ -1,4 +1,4 @@ -"""Tests for DIVES-5: suggested-questions gallery (routes.dives.SUGGESTED_QUESTIONS). +"""Tests for DIVES-5: suggested-questions gallery (routes.dives.DIVES_GALLERY_QUESTIONS). Regression guards: - Fixed schema per entry (question, chart_type, category). From 2cac3c4bf74d8e99dbaeac7c7b67aefb8c43d05a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 12:36:21 +0000 Subject: [PATCH 7/7] fix(dives): derive _KNOWN_CHART_TYPES from SUPPORTED_CHART_TYPES directly Eliminates the duplication the Drift Bot flagged across three passes. The test now imports the canonical set from clawmetry.dives_prompt so chart-type drift is impossible by construction. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01LLvMiVdekbBQ5eSWRDqncG --- tests/test_dives_questions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_dives_questions.py b/tests/test_dives_questions.py index d398a0f01e..db49eb8291 100644 --- a/tests/test_dives_questions.py +++ b/tests/test_dives_questions.py @@ -30,9 +30,10 @@ sys.modules["flask"].jsonify = MagicMock() sys.modules["flask"].request = MagicMock() +from clawmetry.dives_prompt import SUPPORTED_CHART_TYPES # noqa: E402 from routes.dives import DIVES_GALLERY_QUESTIONS as SUGGESTED_QUESTIONS # noqa: E402 -_KNOWN_CHART_TYPES = frozenset({"bar", "line", "pie", "table", "number"}) +_KNOWN_CHART_TYPES = frozenset(SUPPORTED_CHART_TYPES) _KNOWN_CATEGORIES = frozenset({"cost", "activity", "sessions", "crons", "system", "memory"}) # One-way ratchet — update intentionally when adding or removing entries.