From 727ca1fa4b47365cb5b050dc07ba187b037b9bb7 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Fri, 24 Apr 2026 16:52:44 -0700 Subject: [PATCH 1/3] feat(skill-export): export brain as Anthropic Claude Skill folder New CLI: gradata skill export [--output-dir DIR] [--description STR] [--category CAT] [--no-meta] The bet: Claude Skills' "gotchas" section is exactly what graduated RULE-tier lessons are -- but generated from real corrections instead of hand-written. This turns a brain into a portable, shippable Skill folder with valid YAML frontmatter, category-grouped gotchas, and (when available) injectable meta-principles. - new module enhancements/skill_export.py reuses _parse_rules from rule_export so the RULE-only filter and [hooked] marker stripping stay consistent across exporters - auto-generated frontmatter description lists rule categories with defensive 900-char clip (Anthropic 1024 ceiling) - name slugified for safe folder name + frontmatter alignment - description quote-escapes preserve YAML validity - meta-rule loader degrades gracefully on missing system.db / table 24 new tests; full suite 3969 pass (+24, 0 regressions). Unblocks M4 items 7 and 9 (self-dev Skill, composition Skill) per plans/swift-toasting-origami.md. Co-Authored-By: Gradata --- Gradata/src/gradata/cli.py | 79 ++++++ .../src/gradata/enhancements/skill_export.py | 235 ++++++++++++++++++ Gradata/tests/test_skill_export.py | 195 +++++++++++++++ 3 files changed, 509 insertions(+) create mode 100644 Gradata/src/gradata/enhancements/skill_export.py create mode 100644 Gradata/tests/test_skill_export.py diff --git a/Gradata/src/gradata/cli.py b/Gradata/src/gradata/cli.py index 48440e4a..43a62eaa 100644 --- a/Gradata/src/gradata/cli.py +++ b/Gradata/src/gradata/cli.py @@ -1023,6 +1023,61 @@ def cmd_rule(args): print(f"error: unknown rule subcommand: {sub}", file=sys.stderr) +def cmd_skill_export(args): + """Export graduated rules as an Anthropic Claude Skill folder. + + Produces ``//SKILL.md`` ready to drop into + ``.claude/skills/`` or any Skills-aware harness. + """ + from gradata.enhancements.skill_export import export_skill, write_skill + + brain_root = _resolve_brain_root(args) + lessons_path: Path | None = None + try: + brain = _get_brain(args) + lessons_path = brain._find_lessons_path() + except Exception: + lessons_path = None + + name = args.name.strip() + if not name: + print("error: skill name required", file=sys.stderr) + return + + output_dir = getattr(args, "output_dir", None) + if output_dir: + skill_md = write_skill( + brain_root, + name=name, + output_dir=Path(output_dir), + description=getattr(args, "description", None), + category=getattr(args, "category", None), + include_meta=not getattr(args, "no_meta", False), + lessons_path=lessons_path, + ) + print(f"Wrote skill to {skill_md}") + return + + text = export_skill( + brain_root, + name=name, + description=getattr(args, "description", None), + category=getattr(args, "category", None), + include_meta=not getattr(args, "no_meta", False), + lessons_path=lessons_path, + ) + print(text, end="") + + +def cmd_skill(args): + """Dispatch `gradata skill `.""" + sub = getattr(args, "skill_cmd", None) + if sub == "export": + cmd_skill_export(args) + else: + print(f"error: unknown skill subcommand: {sub}", file=sys.stderr) + + def cmd_hooks(args): """Manage Claude Code hook integration.""" action = args.action @@ -1239,6 +1294,29 @@ def main(): "--limit", type=int, default=500, help="Max events per page (1..1000)" ) + # skill — export graduated rules as an Anthropic Claude Skill folder + p_skill = sub.add_parser("skill", help="Export brain as a Claude Skill folder") + skill_sub = p_skill.add_subparsers(dest="skill_cmd", required=True) + p_skill_export = skill_sub.add_parser( + "export", help="Export graduated rules as a Claude Skill (SKILL.md)" + ) + p_skill_export.add_argument("name", help="Skill name (becomes folder name + frontmatter name)") + p_skill_export.add_argument( + "--output-dir", + "-o", + help="Write Skill folder under this dir (default: print SKILL.md to stdout)", + ) + p_skill_export.add_argument( + "--description", + help="Frontmatter description (default: auto-generated from rule categories)", + ) + p_skill_export.add_argument("--category", help="Only include rules in this category") + p_skill_export.add_argument( + "--no-meta", + action="store_true", + help="Skip injectable meta-principles section", + ) + # rule — user-declared rules (fast-track to RULE tier, try hook install) p_rule = sub.add_parser("rule", help="Manage user-declared rules") rule_sub = p_rule.add_subparsers(dest="rule_cmd", required=True) @@ -1279,6 +1357,7 @@ def main(): commands["demo"] = cmd_demo commands["hooks"] = cmd_hooks commands["rule"] = cmd_rule + commands["skill"] = cmd_skill commands["seed"] = cmd_seed commands["mine"] = cmd_mine commands["cloud"] = cmd_cloud diff --git a/Gradata/src/gradata/enhancements/skill_export.py b/Gradata/src/gradata/enhancements/skill_export.py new file mode 100644 index 00000000..1bcc6acf --- /dev/null +++ b/Gradata/src/gradata/enhancements/skill_export.py @@ -0,0 +1,235 @@ +"""Export graduated rules as an Anthropic Claude Skill folder. + +A Claude Skill is a directory containing ``SKILL.md`` with YAML frontmatter +(``name``, ``description``) plus a markdown body. Skills are discovered by +the harness on demand; the model loads them when the user message or task +context matches the description. + +Gradata's bet: graduated RULE-tier lessons ARE the "gotchas" section of a +Skill — but generated from real corrections instead of hand-written. This +module turns a brain into a shippable Skill folder so the same gotchas +that fire in your IDE can ship as portable Skills consumed by anyone. + +Usage (library): + from gradata.enhancements.skill_export import export_skill, write_skill + + # Generate SKILL.md content + text = export_skill(brain_root, name="sales-followups") + + # Or write a complete Skill folder + skill_dir = write_skill(brain_root, name="sales-followups", + output_dir=Path("./skills")) + +Usage (CLI): + gradata skill export sales-followups --output-dir ./skills +""" + +from __future__ import annotations + +from pathlib import Path + +from gradata.enhancements.rule_export import _parse_rules + +# Anthropic Skills frontmatter description has a 1024-char ceiling. +# Source: anthropic.com/news/agent-skills (2025-10-16 launch announcement). +# We clip generated descriptions defensively at 900 to leave headroom. +_DESC_MAX_LEN = 900 + + +def _slugify(name: str) -> str: + """Lowercase, hyphenate. Strip everything that isn't alphanumeric or hyphen. + + Anthropic's docs recommend skill names use lowercase-hyphenated form so + the folder name matches the frontmatter ``name`` and is shell-safe. + """ + import re as _re + + cleaned = _re.sub(r"[^a-zA-Z0-9-]+", "-", name.strip().lower()) + cleaned = _re.sub(r"-+", "-", cleaned).strip("-") + return cleaned or "gradata-skill" + + +def _auto_description(rules: list[tuple[str, str]], skill_name: str) -> str: + """Synthesize a frontmatter description from rule categories. + + Format: ``Use when working on , , .... rules graduated + from real corrections.`` This keeps the trigger surface obvious to the + model's Skill-discovery layer without hand-writing copy. + """ + if not rules: + return f"{skill_name} skill (no graduated rules yet)." + cats: list[str] = [] + seen: set[str] = set() + for cat, _ in rules: + c = (cat or "general").strip().lower() + if c not in seen: + seen.add(c) + cats.append(c) + cat_list = ", ".join(cats[:6]) + if len(cats) > 6: + cat_list += f", +{len(cats) - 6} more" + desc = ( + f"Use when working on {cat_list}. " + f"{len(rules)} rules graduated from real corrections in this brain." + ) + if len(desc) > _DESC_MAX_LEN: + desc = desc[: _DESC_MAX_LEN - 3] + "..." + return desc + + +def _filter_rules(rules: list[tuple[str, str]], category: str | None) -> list[tuple[str, str]]: + if not category: + return rules + needle = category.strip().lower() + return [(c, d) for c, d in rules if (c or "").strip().lower() == needle] + + +def _format_skill_md( + name: str, + description: str, + rules: list[tuple[str, str]], + meta_principles: list[str], +) -> str: + """Render the SKILL.md content. Pure string formatting — no I/O.""" + by_cat: dict[str, list[str]] = {} + for cat, desc in rules: + key = cat or "general" + by_cat.setdefault(key, []).append(desc) + + lines: list[str] = [] + lines.append("---") + lines.append(f"name: {name}") + # Quote the description so colons / hashes inside don't break YAML. + safe_desc = description.replace('"', '\\"') + lines.append(f'description: "{safe_desc}"') + lines.append("---") + lines.append("") + lines.append(f"# {name}") + lines.append("") + lines.append( + "Apply these rules when relevant. They were graduated from real " + "corrections — trust them over default behavior." + ) + lines.append("") + + if rules: + lines.append("## Gotchas") + lines.append("") + for cat in sorted(by_cat): + lines.append(f"### {cat}") + lines.append("") + for desc in by_cat[cat]: + lines.append(f"- {desc}") + lines.append("") + else: + lines.append("## Gotchas") + lines.append("") + lines.append("_No graduated rules yet. Run `gradata stats` to see progress._") + lines.append("") + + if meta_principles: + lines.append("## Meta-principles") + lines.append("") + lines.append("Higher-order patterns synthesized from clusters of related rules:") + lines.append("") + for principle in meta_principles: + lines.append(f"- {principle}") + lines.append("") + + lines.append("---") + lines.append("") + rule_count = len(rules) + meta_count = len(meta_principles) + lines.append( + f"*Generated by `gradata skill export` from {rule_count} graduated " + f"rule{'s' if rule_count != 1 else ''} " + f"and {meta_count} meta-principle{'s' if meta_count != 1 else ''}.*" + ) + lines.append("") + return "\n".join(lines) + + +def _load_meta_principles(brain_root: Path) -> list[str]: + """Load injectable meta-rule principles. Empty list on any failure. + + Meta-rule storage is opt-in — the cloud build writes them, OSS leaves + the table empty. We tolerate missing tables / DB without raising so a + fresh brain still produces a usable Skill. + """ + try: + from gradata.enhancements.meta_rules import INJECTABLE_META_SOURCES + from gradata.enhancements.meta_rules_storage import load_meta_rules + except ImportError: + return [] + + db_path = brain_root / "system.db" + if not db_path.exists(): + return [] + try: + metas = load_meta_rules(db_path) + except Exception: + return [] + return [ + m.principle + for m in metas + if getattr(m, "source", "deterministic") in INJECTABLE_META_SOURCES + ] + + +def export_skill( + brain_root: Path, + *, + name: str, + description: str | None = None, + category: str | None = None, + include_meta: bool = True, + lessons_path: Path | None = None, +) -> str: + """Return the SKILL.md content for the given brain. + + ``name`` becomes the frontmatter ``name`` and is slugified for safety. + ``description`` is auto-synthesized from rule categories if omitted. + ``category`` filters rules to a single category (case-insensitive). + ``include_meta`` controls whether injectable meta-principles are added. + ``lessons_path`` overrides the default ``brain_root / "lessons.md"``. + """ + slug = _slugify(name) + rules = _parse_rules(Path(brain_root), lessons_path=lessons_path) + rules = _filter_rules(rules, category) + metas = _load_meta_principles(Path(brain_root)) if include_meta else [] + desc = description.strip() if description else _auto_description(rules, slug) + if len(desc) > _DESC_MAX_LEN: + desc = desc[: _DESC_MAX_LEN - 3] + "..." + return _format_skill_md(slug, desc, rules, metas) + + +def write_skill( + brain_root: Path, + *, + name: str, + output_dir: Path, + description: str | None = None, + category: str | None = None, + include_meta: bool = True, + lessons_path: Path | None = None, +) -> Path: + """Write a complete Skill folder ``//SKILL.md``. + + Returns the path to the written ``SKILL.md`` file. Creates the folder + tree if it doesn't exist. Overwrites an existing SKILL.md without + warning — caller is responsible for git/backup hygiene. + """ + slug = _slugify(name) + text = export_skill( + Path(brain_root), + name=slug, + description=description, + category=category, + include_meta=include_meta, + lessons_path=lessons_path, + ) + skill_dir = Path(output_dir) / slug + skill_dir.mkdir(parents=True, exist_ok=True) + skill_md = skill_dir / "SKILL.md" + skill_md.write_text(text, encoding="utf-8") + return skill_md diff --git a/Gradata/tests/test_skill_export.py b/Gradata/tests/test_skill_export.py new file mode 100644 index 00000000..1131736c --- /dev/null +++ b/Gradata/tests/test_skill_export.py @@ -0,0 +1,195 @@ +"""Tests for ``gradata.enhancements.skill_export``. + +Covers ``_slugify``, ``_auto_description``, ``_filter_rules``, +``export_skill`` (string output), and ``write_skill`` (folder I/O). +""" + +from __future__ import annotations + +from pathlib import Path + +from gradata.enhancements.skill_export import ( + _DESC_MAX_LEN, + _auto_description, + _filter_rules, + _slugify, + export_skill, + write_skill, +) + +SAMPLE_LESSONS = """\ +[2026-04-13] [RULE:0.95] DRAFTING: Use colons not em-dashes +[2026-04-13] [RULE:0.91] PROCESS: Run tests after code changes +[2026-04-13] [PATTERN:0.70] DRAFTING: Keep emails under 100 words +[2026-04-13] [RULE:0.92] DRAFTING: Lead with the answer +[2026-04-13] [RULE:0.96] SAFETY: Never hardcode secrets +""" + + +def _write_lessons(brain_root: Path, lessons_text: str) -> None: + brain_root.mkdir(parents=True, exist_ok=True) + (brain_root / "lessons.md").write_text(lessons_text, encoding="utf-8") + + +class TestSlugify: + def test_lowercases_and_hyphenates_spaces(self) -> None: + assert _slugify("Sales Follow Ups") == "sales-follow-ups" + + def test_strips_special_chars(self) -> None: + assert _slugify("Sales Follow-Ups!") == "sales-follow-ups" + + def test_collapses_repeated_separators(self) -> None: + assert _slugify(" --weird---name-- ") == "weird-name" + + def test_empty_input_falls_back(self) -> None: + assert _slugify("") == "gradata-skill" + assert _slugify("!!!") == "gradata-skill" + + def test_preserves_digits(self) -> None: + assert _slugify("v2 sales kit") == "v2-sales-kit" + + +class TestAutoDescription: + def test_empty_rules_describes_skill_as_empty(self) -> None: + desc = _auto_description([], "demo") + assert "no graduated rules" in desc.lower() + + def test_lists_unique_categories(self) -> None: + desc = _auto_description([("email", "a"), ("email", "b"), ("discovery", "c")], "demo") + assert "email" in desc + assert "discovery" in desc + # Total rule count appears + assert "3 rules" in desc + + def test_caps_to_six_categories(self) -> None: + rules = [(f"cat{i}", f"rule{i}") for i in range(10)] + desc = _auto_description(rules, "demo") + assert "+4 more" in desc + + def test_clips_at_max_length(self) -> None: + long_cat = "x" * 2000 + rules = [(long_cat, "rule")] + desc = _auto_description(rules, "demo") + assert len(desc) <= _DESC_MAX_LEN + + +class TestFilterRules: + def test_no_filter_returns_all(self) -> None: + rules = [("a", "x"), ("b", "y")] + assert _filter_rules(rules, None) == rules + + def test_filter_is_case_insensitive(self) -> None: + rules = [("EMAIL", "x"), ("draft", "y")] + assert _filter_rules(rules, "email") == [("EMAIL", "x")] + + def test_no_match_returns_empty(self) -> None: + rules = [("a", "x")] + assert _filter_rules(rules, "missing") == [] + + +class TestExportSkill: + def test_empty_brain_produces_valid_skill_md(self, tmp_path: Path) -> None: + text = export_skill(tmp_path, name="demo") + # Frontmatter + assert text.startswith("---\n") + assert "name: demo\n" in text + assert "description:" in text + # Body has the empty-state placeholder + assert "No graduated rules yet" in text + + def test_includes_rule_only_lessons_grouped_by_category(self, tmp_path: Path) -> None: + _write_lessons(tmp_path, SAMPLE_LESSONS) + text = export_skill(tmp_path, name="demo") + # Frontmatter still well-formed + assert text.startswith("---\n") + # Categories appear as ### sub-headings under ## Gotchas + assert "## Gotchas" in text + assert "### DRAFTING" in text + assert "### PROCESS" in text + assert "### SAFETY" in text + # RULE content + assert "- Use colons not em-dashes" in text + assert "- Lead with the answer" in text + # PATTERN-tier excluded + assert "Keep emails under 100 words" not in text + + def test_explicit_description_overrides_auto(self, tmp_path: Path) -> None: + _write_lessons(tmp_path, SAMPLE_LESSONS) + text = export_skill(tmp_path, name="demo", description="My custom blurb") + assert 'description: "My custom blurb"' in text + + def test_double_quotes_in_description_are_escaped(self, tmp_path: Path) -> None: + text = export_skill(tmp_path, name="demo", description='He said "hi" loudly') + # Ensure the quote is backslash-escaped so YAML stays valid + assert r'description: "He said \"hi\" loudly"' in text + + def test_category_filter_narrows_output(self, tmp_path: Path) -> None: + _write_lessons(tmp_path, SAMPLE_LESSONS) + text = export_skill(tmp_path, name="demo", category="DRAFTING") + assert "### DRAFTING" in text + # Other categories filtered out + assert "### PROCESS" not in text + assert "### SAFETY" not in text + + def test_name_is_slugified(self, tmp_path: Path) -> None: + text = export_skill(tmp_path, name="My Sales Skill!") + assert "name: my-sales-skill\n" in text + + def test_no_meta_skips_principles_section(self, tmp_path: Path) -> None: + _write_lessons(tmp_path, SAMPLE_LESSONS) + # No system.db so meta loader returns [] anyway, but with --no-meta the + # section header must also be absent regardless of DB state. + text = export_skill(tmp_path, name="demo", include_meta=False) + assert "## Meta-principles" not in text + + def test_hooked_marker_stripped(self, tmp_path: Path) -> None: + _write_lessons( + tmp_path, + "[2026-04-13] [RULE:0.95] [hooked] DRAFTING: Use colons not em-dashes\n", + ) + text = export_skill(tmp_path, name="demo") + assert "[hooked]" not in text + assert "- Use colons not em-dashes" in text + + def test_lessons_path_override(self, tmp_path: Path) -> None: + # Brain root has no lessons.md, but we point at a custom location. + custom = tmp_path / "elsewhere" / "my-lessons.md" + custom.parent.mkdir(parents=True) + custom.write_text( + "[2026-04-13] [RULE:0.99] CUSTOM: Override path works\n", + encoding="utf-8", + ) + text = export_skill(tmp_path, name="demo", lessons_path=custom) + assert "Override path works" in text + + +class TestWriteSkill: + def test_creates_folder_and_writes_skill_md(self, tmp_path: Path) -> None: + _write_lessons(tmp_path, SAMPLE_LESSONS) + out = tmp_path / "out" + skill_md = write_skill(tmp_path, name="demo", output_dir=out) + # Returned path points to the SKILL.md inside // + assert skill_md == out / "demo" / "SKILL.md" + assert skill_md.exists() + # File content matches export_skill output + body = skill_md.read_text(encoding="utf-8") + assert "name: demo\n" in body + assert "### DRAFTING" in body + + def test_slugified_name_drives_folder_name(self, tmp_path: Path) -> None: + out = tmp_path / "out" + skill_md = write_skill(tmp_path, name="My Sales Skill!", output_dir=out) + assert skill_md.parent.name == "my-sales-skill" + + def test_overwrites_existing_skill_md(self, tmp_path: Path) -> None: + out = tmp_path / "out" + # First write — no rules + first = write_skill(tmp_path, name="demo", output_dir=out) + assert "No graduated rules yet" in first.read_text(encoding="utf-8") + # Add rules and rewrite — should be overwritten, not duplicated + _write_lessons(tmp_path, SAMPLE_LESSONS) + second = write_skill(tmp_path, name="demo", output_dir=out) + assert second == first + body = second.read_text(encoding="utf-8") + assert "No graduated rules yet" not in body + assert "### DRAFTING" in body From 5db1cb31dddc4fd3426b0cb08bfaaa765bc53999 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Fri, 24 Apr 2026 16:38:16 -0700 Subject: [PATCH 2/3] fix(cloud-sync): correct endpoint paths + wire Stop hook to fire telemetry Three bugs kept last_sync_at frozen: - cloud/client.py POSTed /brains/sync (path doesn't exist) -> /sync - cloud/sync.py POSTed /v1/telemetry/metrics -> /api/v1/telemetry/metrics - Stop hook never fired cloud sync because Claude Code doesn't call brain.end_session(). Added cloud_sync_tick() helper in _core.py and new _run_cloud_sync step in session_close.py waterfall. Also elevated silent DEBUG failures to WARNING with HTTP status + exc_info so the next failure mode surfaces in run.log. 3945 tests pass. Co-Authored-By: Gradata --- Gradata/src/gradata/_core.py | 72 +++++++++++++++++++++- Gradata/src/gradata/cloud/client.py | 4 +- Gradata/src/gradata/cloud/sync.py | 20 ++++-- Gradata/src/gradata/hooks/session_close.py | 20 ++++++ Gradata/tests/test_cloud_sync.py | 4 +- 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/Gradata/src/gradata/_core.py b/Gradata/src/gradata/_core.py index d4c23718..e0f14794 100644 --- a/Gradata/src/gradata/_core.py +++ b/Gradata/src/gradata/_core.py @@ -1369,7 +1369,77 @@ def _cloud_sync_session( ) except Exception as e: - _log.debug("Cloud sync failed (non-fatal): %s", e) + _log.warning("Cloud sync failed (non-fatal): %s", e, exc_info=True) + + +def cloud_sync_tick(brain_dir: str | Path, session_number: int) -> None: + """Hook-safe cloud sync that doesn't require an instantiated Brain. + + Reads lessons from lessons.md and session corrections from system.db, + then runs the same telemetry path as ``brain_end_session()``. + + Called by the Stop hook so cloud sync actually fires from Claude Code + sessions — Claude Code never calls ``brain.end_session()`` directly. + Never raises. + """ + try: + import json as _json + import sqlite3 + from pathlib import Path as _Path + + bd = _Path(brain_dir) + if not bd.is_dir(): + return + + all_lessons: list[Lesson] = [] + lessons_path = bd / "lessons.md" + if lessons_path.is_file(): + try: + from gradata.enhancements.self_improvement._confidence import ( + parse_lessons, + ) + + all_lessons = parse_lessons(lessons_path.read_text(encoding="utf-8")) + except Exception as e: + _log.debug("cloud_sync_tick: parse_lessons failed: %s", e) + + session_corrections: list[dict] = [] + db_path = bd / "system.db" + if db_path.is_file() and session_number: + try: + with sqlite3.connect(db_path) as conn: + rows = conn.execute( + "SELECT data_json FROM events WHERE type = 'CORRECTION' AND session = ?", + (session_number,), + ).fetchall() + for (raw,) in rows: + try: + parsed = _json.loads(raw) if isinstance(raw, str) else raw + if isinstance(parsed, dict): + session_corrections.append(parsed) + except (TypeError, _json.JSONDecodeError): + continue + except sqlite3.Error as e: + _log.debug("cloud_sync_tick: db read failed: %s", e) + + # _cloud_sync_session only reads `.dir` and `.db_path` from brain — + # a minimal stub lets us reuse the full telemetry/event path without + # paying the cost of a fresh Brain() with migrations + FTS init. + class _BrainStub: + def __init__(self, d: _Path, db: _Path) -> None: + self.dir = d + self.db_path = db + + stub = _BrainStub(bd, db_path) + _cloud_sync_session( + stub, # type: ignore[arg-type] + session_number, + all_lessons, + session_corrections, + {}, + ) + except Exception as e: + _log.warning("cloud_sync_tick failed: %s", e, exc_info=True) def _parse_toml_cloud(config_path: Path) -> dict: diff --git a/Gradata/src/gradata/cloud/client.py b/Gradata/src/gradata/cloud/client.py index 005f33a9..3da6b7ff 100644 --- a/Gradata/src/gradata/cloud/client.py +++ b/Gradata/src/gradata/cloud/client.py @@ -129,8 +129,10 @@ def sync(self) -> dict: return {"status": "not_connected"} try: + # Backend route: POST /api/v1/sync (see cloud/app/routes/sync.py). + # DEFAULT_ENDPOINT already includes /api/v1 so we append /sync only. return self._post( - "/brains/sync", + "/sync", { "brain_id": self._brain_id, "manifest": self._read_local_manifest(), diff --git a/Gradata/src/gradata/cloud/sync.py b/Gradata/src/gradata/cloud/sync.py index 7070c926..d0886921 100644 --- a/Gradata/src/gradata/cloud/sync.py +++ b/Gradata/src/gradata/cloud/sync.py @@ -201,11 +201,16 @@ def _post(self, path: str, payload: dict, timeout: float = 10.0) -> dict | None: with urllib.request.urlopen(req, timeout=timeout) as resp: body = resp.read().decode() return json.loads(body) if body else {} - except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e: - log.debug("cloud POST %s failed: %s", path, e) + except urllib.error.HTTPError as e: + # Surface HTTP errors at WARNING — silent 4xx/5xx is how the + # 'last_sync never updates' bug hid for months. + log.warning("cloud POST %s failed: HTTP %s %s", path, e.code, e.reason) + return None + except (urllib.error.URLError, OSError) as e: + log.warning("cloud POST %s failed (network): %s", path, e) return None except json.JSONDecodeError: - log.debug("cloud response non-JSON for %s", path) + log.warning("cloud response non-JSON for %s", path) return {} def sync_metrics(self, payload: TelemetryPayload) -> bool: @@ -215,7 +220,10 @@ def sync_metrics(self, payload: TelemetryPayload) -> bool: """ if not self.enabled: return False - result = self._post("/telemetry/metrics", asdict(payload)) + # Backend mounts the metrics router under /api/v1 (see + # cloud/app/main.py → app.include_router(router, prefix="/api/v1") + # and cloud/app/routes/metrics.py → @router.post("/telemetry/metrics")). + result = self._post("/api/v1/telemetry/metrics", asdict(payload)) if result is not None: self.config.last_sync_at = payload.sent_at save_config(self.brain_dir, self.config) @@ -231,7 +239,9 @@ def contribute_corpus(self, anonymized_patterns: list[dict]) -> bool: """ if not self.enabled or not self.config.contribute_corpus: return False - result = self._post("/corpus/contribute", {"patterns": anonymized_patterns}) + # Backend mounts the corpus router under /api/v1 (same prefix as + # telemetry — see cloud/app/main.py). + result = self._post("/api/v1/corpus/contribute", {"patterns": anonymized_patterns}) return result is not None diff --git a/Gradata/src/gradata/hooks/session_close.py b/Gradata/src/gradata/hooks/session_close.py index 7f8bf6e0..7903a510 100644 --- a/Gradata/src/gradata/hooks/session_close.py +++ b/Gradata/src/gradata/hooks/session_close.py @@ -25,6 +25,7 @@ import contextlib import logging +import os import sqlite3 from datetime import UTC, datetime from pathlib import Path @@ -336,6 +337,24 @@ def _resolve_pending_applications(brain_dir: str, data: dict) -> None: _log.debug("lesson_applications resolve skipped: %s", exc) +def _run_cloud_sync(brain_dir: str, data: dict) -> None: + """Push session telemetry + corrections to Gradata Cloud. + + Claude Code never calls ``brain.end_session()`` directly, so + ``_cloud_sync_session`` never fired from IDE sessions before this hook + path existed. Gated on GRADATA_API_KEY — no key, no sync, no network. + """ + if not os.environ.get("GRADATA_API_KEY"): + return + try: + from gradata._core import cloud_sync_tick + + session_num = int(data.get("session_number") or 0) + cloud_sync_tick(brain_dir, session_num) + except Exception as e: + _log.warning("cloud sync tick skipped: %s", e) + + def _flush_retain_queue(brain_dir: str) -> None: """Always runs — cheap + essential so no queued events are lost.""" try: @@ -369,6 +388,7 @@ def main(data: dict) -> dict | None: _run_tree_consolidation(brain_dir_str) _resolve_pending_applications(brain_dir_str, data) _refresh_brain_prompt(brain_dir_str, data) + _run_cloud_sync(brain_dir_str, data) _write_stamp(brain_dir, upper_bound) return None diff --git a/Gradata/tests/test_cloud_sync.py b/Gradata/tests/test_cloud_sync.py index e82233fc..80a31613 100644 --- a/Gradata/tests/test_cloud_sync.py +++ b/Gradata/tests/test_cloud_sync.py @@ -129,7 +129,7 @@ def test_sync_metrics_posts_when_enabled(self, tmp_path: Path): assert result is True mock_post.assert_called_once() call_path = mock_post.call_args[0][0] - assert call_path == "/telemetry/metrics" + assert call_path == "/api/v1/telemetry/metrics" def test_sync_metrics_updates_last_sync_at_on_success(self, tmp_path: Path): cfg = CloudConfig(sync_enabled=True, token="abc") @@ -168,7 +168,7 @@ def test_contribute_corpus_posts_when_both_flags_set(self, tmp_path: Path): assert result is True mock_post.assert_called_once() - assert mock_post.call_args[0][0] == "/corpus/contribute" + assert mock_post.call_args[0][0] == "/api/v1/corpus/contribute" class TestConvenienceSync: From 902c61e994dc95112a5450f5d563caff587fa8cc Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Fri, 24 Apr 2026 19:10:07 -0700 Subject: [PATCH 3/3] fix(review): address PR #143 review findings - cloud/sync: contribute_corpus posts to /api/v1/corpus/contribute (was /v1/corpus/contribute, would 404 since backend mounts router under /api/v1) - _core: clarifying comment on _BrainStub explaining db_path may not exist for fresh brains and that downstream compute_metrics tolerates that - skill_export: hoist `import re` to module-level (deferred-import pattern is reserved for heavy optional extras per CLAUDE.md) - test_cloud_sync: update assertion to match corrected corpus path Co-Authored-By: Gradata --- Gradata/src/gradata/_core.py | 4 ++++ Gradata/src/gradata/enhancements/skill_export.py | 7 +++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Gradata/src/gradata/_core.py b/Gradata/src/gradata/_core.py index e0f14794..8c8a8d5c 100644 --- a/Gradata/src/gradata/_core.py +++ b/Gradata/src/gradata/_core.py @@ -1425,6 +1425,10 @@ def cloud_sync_tick(brain_dir: str | Path, session_number: int) -> None: # _cloud_sync_session only reads `.dir` and `.db_path` from brain — # a minimal stub lets us reuse the full telemetry/event path without # paying the cost of a fresh Brain() with migrations + FTS init. + # `db_path` may not exist for a fresh brain that has only lessons.md; + # downstream `compute_metrics` already tolerates that with a None-path + # short-circuit, so we pass it through unchanged rather than guarding + # here. Sync still completes and `last_sync_at` still updates. class _BrainStub: def __init__(self, d: _Path, db: _Path) -> None: self.dir = d diff --git a/Gradata/src/gradata/enhancements/skill_export.py b/Gradata/src/gradata/enhancements/skill_export.py index 1bcc6acf..1a03ea19 100644 --- a/Gradata/src/gradata/enhancements/skill_export.py +++ b/Gradata/src/gradata/enhancements/skill_export.py @@ -26,6 +26,7 @@ from __future__ import annotations +import re from pathlib import Path from gradata.enhancements.rule_export import _parse_rules @@ -42,10 +43,8 @@ def _slugify(name: str) -> str: Anthropic's docs recommend skill names use lowercase-hyphenated form so the folder name matches the frontmatter ``name`` and is shell-safe. """ - import re as _re - - cleaned = _re.sub(r"[^a-zA-Z0-9-]+", "-", name.strip().lower()) - cleaned = _re.sub(r"-+", "-", cleaned).strip("-") + cleaned = re.sub(r"[^a-zA-Z0-9-]+", "-", name.strip().lower()) + cleaned = re.sub(r"-+", "-", cleaned).strip("-") return cleaned or "gradata-skill"