Skip to content

feat(m1): skill export + cloud sync fixes#144

Merged
Gradata merged 3 commits intomainfrom
feat/skill-export-m1-clean
May 1, 2026
Merged

feat(m1): skill export + cloud sync fixes#144
Gradata merged 3 commits intomainfrom
feat/skill-export-m1-clean

Conversation

@Gradata
Copy link
Copy Markdown
Owner

@Gradata Gradata commented Apr 25, 2026

Summary

Clean 3-commit rebase of the M1 work onto current main. Supersedes #143 which had 38 stale commits conflicting with #142 (cloud Phase 2).

  • feat(skill-export): new module enhancements/skill_export.py + CLI gradata skill export <name> — turns graduated RULE-tier lessons into a portable Anthropic Claude Skill folder with valid YAML frontmatter, category-grouped gotchas, and (when available) injectable meta-principles. Reuses _parse_rules from rule_export for marker stripping consistency.
  • fix(cloud-sync): corrects POST paths (/api/v1/telemetry/metrics, /api/v1/corpus/contribute); wires _run_cloud_sync into the session_close waterfall so Stop hook actually fires telemetry (Claude Code never calls brain.end_session() so _cloud_sync_session was never executing); elevates silent DEBUG → WARNING with HTTP status + exc_info so the next failure mode surfaces.
  • fix(review): addresses feat: M1 Skills export + cloud sync fixes (batch of 41 commits) #143 reviewer findings — corpus path fix, _BrainStub clarifying comment, hoist import re to module-level (deferred-import pattern reserved for heavy optional extras per CLAUDE.md).

Test plan

Generated with Gradata

Gradata and others added 3 commits April 24, 2026 19:18
New CLI: gradata skill export <name> [--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 <noreply@gradata.ai>
…metry

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 <noreply@gradata.ai>
- 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 <noreply@gradata.ai>
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

📝 Walkthrough
  • New skill export feature: Added CLI command gradata skill export <name> and skill_export.py module to convert graduated RULE-tier lessons into Anthropic Claude Skill folders with YAML frontmatter, category-grouped gotchas, and optional meta-principles
  • Cloud sync telemetry fixes: Corrected POST endpoint paths to /api/v1/telemetry/metrics and /api/v1/corpus/contribute (aligned with backend routes) and fixed client POST path to /sync
  • Cloud sync integration: Wired _run_cloud_sync step into session close hook so Stop triggers telemetry; added cloud_sync_tick() helper for hook-safe cloud sync without full Brain instantiation
  • Improved error visibility: Elevated silent DEBUG failures to WARNING level with HTTP status codes and exception traceback info
  • New public APIs: export_skill() and write_skill() for skill generation; cloud_sync_tick(brain_dir, session_number) for telemetry triggering
  • Code quality improvements: Hoisted import re to module level per guidelines; added clarifying comments on _BrainStub behavior; graceful degradation when meta-rule data is missing
  • Test coverage: 24 new tests for skill export; cloud sync tests updated for corrected API paths; 45 new tests total, 4062+ passing with zero regressions

Walkthrough

This PR introduces cloud sync improvements, a new skill export feature for generating Anthropic Claude Skills from graduated rules, and CLI integration. Changes include enhanced telemetry logging, a hook-safe cloud sync entrypoint, skill markdown generation with filesystem output, and updated API endpoint paths for backend alignment.

Changes

Cohort / File(s) Summary
Cloud Sync Core
Gradata/src/gradata/_core.py, Gradata/src/gradata/cloud/client.py, Gradata/src/gradata/cloud/sync.py, Gradata/src/gradata/hooks/session_close.py
Enhanced telemetry failure logging (DEBUG → WARNING with exc_info), new cloud_sync_tick() entrypoint for session sync without Brain instantiation, updated API endpoints to include /api/v1 prefix, and integrated cloud sync into session-close hook gated by GRADATA_API_KEY.
Skill Export Feature
Gradata/src/gradata/enhancements/skill_export.py, Gradata/src/gradata/cli.py
New skill_export module generating Claude Skill markdown from graduated rules with optional metadata/description, category filtering, and filesystem output; new gradata skill export CLI command routing through dispatcher handlers.
Test Updates
Gradata/tests/test_cloud_sync.py, Gradata/tests/test_skill_export.py
Updated cloud sync tests for /api/v1-prefixed endpoint assertions; comprehensive new test module for skill export covering slugification, auto-description, rule filtering, markdown structure, and filesystem operations.

Sequence Diagram

sequenceDiagram
    actor User
    participant Session as Session<br/>(hooks)
    participant Core as cloud_sync_tick<br/>(_core.py)
    participant Disk as lessons.md +<br/>system.db
    participant Client as CloudClient
    participant API as Gradata Cloud<br/>API

    User->>Session: Session ends
    Session->>Core: cloud_sync_tick(brain_dir, session_num)
    Core->>Disk: Read lessons.md
    Core->>Disk: Read corrections from system.db
    Disk-->>Core: Rules + telemetry data
    Core->>Client: _cloud_sync_session via _BrainStub
    Client->>API: POST /api/v1/telemetry/metrics
    Client->>API: POST /api/v1/corpus/contribute
    API-->>Client: Response (or error)
    Client-->>Core: Result (or error logged as WARNING)
    Core-->>Session: Complete (swallows errors)
    Session-->>User: Continue execution
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

feature, bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the two main changes: skill export feature and cloud sync fixes, which are the core focus of this PR.
Description check ✅ Passed The description thoroughly explains the changeset across three commits: skill export feature, cloud sync fixes, and review items addressed, directly relating to all modifications.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/skill-export-m1-clean

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added bug Something isn't working feature labels Apr 25, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Gradata/src/gradata/cli.py`:
- Around line 1034-1040: Resolve and reuse the same brain_root when constructing
the Brain so lessons/meta don't come from a different directory: call
_resolve_brain_root(args) once (brain_root) and pass that resolved path into the
brain creation/lookup instead of letting _get_brain() default to cwd; update the
call around brain = _get_brain(args) to use the resolved brain_root (or add a
brain_root argument to _get_brain) and then call lessons_path =
brain._find_lessons_path() so precedence follows GRADATA_BRAIN > --brain-dir >
cwd consistently.

In `@Gradata/src/gradata/cloud/sync.py`:
- Around line 223-226: The POST call in sync.py is constructing a double
"/api/v1" by calling self._post("/api/v1/telemetry/metrics", asdict(payload))
even though api_base already includes "/api/v1"; update the call to use the path
"/telemetry/metrics" (or otherwise join api_base and path without duplicating
the version segment) so the request URL becomes {api_base}/telemetry/metrics;
change the invocation in the method where result = self._post(...,
asdict(payload)) accordingly and keep using the same _post method and payload
variable.
- Around line 242-244: The call to self._post is using a duplicated API prefix
("/api/v1/corpus/contribute") even though the backend already mounts the router
under /api/v1; change the endpoint passed to self._post in the code that assigns
result (the line calling self._post("/api/v1/corpus/contribute", {"patterns":
anonymized_patterns})) to use the route without the "/api/v1" prefix (e.g.
"/corpus/contribute") so the request path is correct.

In `@Gradata/src/gradata/enhancements/skill_export.py`:
- Line 199: The current assignment treats whitespace-only description as
explicit and yields an empty desc; change the logic so you first compute the
stripped value and if it's non-empty use it, otherwise call
_auto_description(rules, slug). In other words, update the desc determination
around the variables description, desc, and the call to _auto_description so
that whitespace-only strings fall back to _auto_description (e.g., check
description and description.strip() before choosing the explicit value).
- Around line 102-103: The YAML description is only escaping double quotes
(safe_desc = description.replace('"', '\\"')) which leaves backslashes unescaped
and can produce invalid YAML; update the logic in skill_export.py to first
escape backslashes and then escape quotes (e.g., transform description by
replacing "\" with "\\" before replacing '"' with '\"') and use that result in
lines.append(f'description: "{safe_desc}"') so backslashes in Windows paths or
other content are preserved and do not create invalid YAML.

In `@Gradata/tests/test_cloud_sync.py`:
- Line 132: The test in test_cloud_sync.py asserts call_path ==
"/api/v1/telemetry/metrics" which mirrors the current double-prefix bug; after
fixing sync_metrics in sync.py to use "/telemetry/metrics", update the assertion
to expect "/telemetry/metrics" instead—locate the assertion comparing call_path
in test_cloud_sync.py and change the expected string to "/telemetry/metrics" so
it matches the corrected sync_metrics behavior.
- Line 171: The corpus contribution path assertion uses the wrong positional
index; update the assertion in tests/test_cloud_sync.py to compare the correct
argument from mock_post.call_args (use the same index used for other path
assertions after fixing sync.py, e.g., mock_post.call_args[0][1] ==
"/api/v1/corpus/contribute") so it checks the actual request path sent by the
code under test.

In `@Gradata/tests/test_skill_export.py`:
- Around line 121-125: Extend the test coverage for YAML frontmatter escaping by
adding cases that exercise backslashes and newlines in the description string
produced by export_skill: call export_skill(tmp_path, name="demo",
description='contains \\ backslash') and assert the returned text contains the
backslash escaped (i.e. the frontmatter shows a double backslash sequence), and
call export_skill(tmp_path, name="demo", description="line1\nline2") and assert
the returned text contains a YAML-safe escaped newline (e.g. "\n" or the
serializer's chosen escaped representation) so both backslash and newline
edge-cases are covered; place these assertions alongside or as additional tests
near test_double_quotes_in_description_are_escaped and reference the
export_skill helper in your assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 604d9b3f-7855-4ae2-88ef-04d79e141c76

📥 Commits

Reviewing files that changed from the base of the PR and between 5635a66 and 902c61e.

📒 Files selected for processing (8)
  • Gradata/src/gradata/_core.py
  • Gradata/src/gradata/cli.py
  • Gradata/src/gradata/cloud/client.py
  • Gradata/src/gradata/cloud/sync.py
  • Gradata/src/gradata/enhancements/skill_export.py
  • Gradata/src/gradata/hooks/session_close.py
  • Gradata/tests/test_cloud_sync.py
  • Gradata/tests/test_skill_export.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: pytest ubuntu-latest / py3.12
  • GitHub Check: pytest ubuntu-latest / py3.11
  • GitHub Check: pytest macos-latest / py3.12
  • GitHub Check: pytest windows-latest / py3.11
  • GitHub Check: pytest windows-latest / py3.12
  • GitHub Check: pytest macos-latest / py3.11
  • GitHub Check: pytest (py3.11)
  • GitHub Check: pytest (py3.12)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: Gradata
Repo: Gradata/gradata PR: 0
File: :0-0
Timestamp: 2026-04-17T17:18:07.439Z
Learning: In PR `#102` (gradata/gradata), Round 2 addressed: cli.py env-first brain resolution (GRADATA_BRAIN > --brain-dir > cwd), _tenant.py corrupt .tenant_id overwrite, _env_int default clamping to minimum, and _events.py tenant-scoped fallback SELECT for dedup. All ruff and 99 tests green after these fixes.
🔇 Additional comments (7)
Gradata/src/gradata/cloud/client.py (1)

132-135: LGTM!

The endpoint path change from /brains/sync to /sync is correctly implemented. The inline comment clearly documents the backend route mapping, and the URL construction (DEFAULT_ENDPOINT already includes /api/v1) produces the expected full path POST /api/v1/sync.

Gradata/src/gradata/cloud/sync.py (1)

204-214: LGTM on error logging elevation.

Elevating HTTP/network errors from DEBUG to WARNING with status codes and descriptive markers improves observability. This aligns with the PR objective to surface silent failures that previously hid issues like the "last_sync never updates" bug.

Gradata/src/gradata/_core.py (2)

1371-1372: Good improvement: Failure logging now includes stack trace.

Elevating to WARNING with exc_info=True ensures cloud sync failures are visible in logs with full traceback context, which aids debugging.


1375-1446: LGTM: Well-designed hook-safe cloud sync entrypoint.

The implementation correctly:

  • Gracefully handles missing lessons.md and system.db
  • Uses a minimal _BrainStub to avoid expensive Brain initialization (migrations, FTS)
  • Wraps everything in try/except to never block the hook
  • Documents the db_path edge case for fresh brains

The comment on lines 1425-1431 clearly explains why the stub passes db_path even when the file doesn't exist.

Gradata/src/gradata/hooks/session_close.py (2)

340-356: LGTM: Clean integration of cloud sync into session close hook.

The implementation correctly:

  • Gates on GRADATA_API_KEY to avoid unnecessary network attempts
  • Uses the hook-safe cloud_sync_tick that doesn't require Brain instantiation
  • Catches exceptions at WARNING level to never block the hook
  • Defaults session_number to 0, which cloud_sync_tick handles gracefully by skipping DB queries

391-391: Correct placement in the hook workflow.

_run_cloud_sync is called after graduation, pipeline, tree consolidation, pending applications, and brain prompt refresh — ensuring telemetry reflects the fully-processed session state before syncing to cloud.

Gradata/src/gradata/cli.py (1)

1297-1319: Skill CLI parser + dispatch wiring looks good

skill export is fully wired (required subcommand, clear flags, and command map registration), so invocation flow is consistent with other top-level commands.

Also applies to: 1360-1360

Comment on lines +1034 to +1040
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Resolve brain root once to avoid cross-brain exports

Line 1034 resolves brain_root via _resolve_brain_root() (default ./brain), but Lines 1037-1038 resolve lessons_path via _get_brain() (default cwd). That can export rules from one brain while loading meta-principles from another.

Suggested fix
 def cmd_skill_export(args):
@@
-    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
+    # Keep one canonical resolution path so rules + meta read from same brain.
+    lessons_path: Path | None = None
+    try:
+        brain = _get_brain(args)
+        brain_root = Path(brain.dir)
+        lessons_path = brain._find_lessons_path()
+    except Exception:
+        brain_root = Path(
+            env_str("GRADATA_BRAIN") or getattr(args, "brain_dir", None) or Path.cwd()
+        )
+        lessons_path = None

Based on learnings: cli.py brain resolution precedence should be GRADATA_BRAIN > --brain-dir > cwd.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
# Keep one canonical resolution path so rules + meta read from same brain.
lessons_path: Path | None = None
try:
brain = _get_brain(args)
brain_root = Path(brain.dir)
lessons_path = brain._find_lessons_path()
except Exception:
brain_root = Path(
env_str("GRADATA_BRAIN") or getattr(args, "brain_dir", None) or Path.cwd()
)
lessons_path = None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/src/gradata/cli.py` around lines 1034 - 1040, Resolve and reuse the
same brain_root when constructing the Brain so lessons/meta don't come from a
different directory: call _resolve_brain_root(args) once (brain_root) and pass
that resolved path into the brain creation/lookup instead of letting
_get_brain() default to cwd; update the call around brain = _get_brain(args) to
use the resolved brain_root (or add a brain_root argument to _get_brain) and
then call lessons_path = brain._find_lessons_path() so precedence follows
GRADATA_BRAIN > --brain-dir > cwd consistently.

Comment on lines +223 to +226
# 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))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Double /api/v1 prefix causes incorrect URL construction.

The api_base (default: https://api.gradata.ai/api/v1) already includes /api/v1, but the path /api/v1/telemetry/metrics also includes it. This results in:

https://api.gradata.ai/api/v1/api/v1/telemetry/metrics

The path should be /telemetry/metrics (without the /api/v1 prefix) since api_base already contains the version segment.

🐛 Proposed fix
-        # 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))
+        # 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")).
+        # api_base already includes /api/v1, so we only append the route path.
+        result = self._post("/telemetry/metrics", asdict(payload))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 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))
# 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")).
# api_base already includes /api/v1, so we only append the route path.
result = self._post("/telemetry/metrics", asdict(payload))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/src/gradata/cloud/sync.py` around lines 223 - 226, The POST call in
sync.py is constructing a double "/api/v1" by calling
self._post("/api/v1/telemetry/metrics", asdict(payload)) even though api_base
already includes "/api/v1"; update the call to use the path "/telemetry/metrics"
(or otherwise join api_base and path without duplicating the version segment) so
the request URL becomes {api_base}/telemetry/metrics; change the invocation in
the method where result = self._post(..., asdict(payload)) accordingly and keep
using the same _post method and payload variable.

Comment on lines +242 to +244
# 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})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Same double-prefix issue for corpus contribution path.

🐛 Proposed fix
-        # 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})
+        # Backend mounts the corpus router under /api/v1 (same prefix as
+        # telemetry — see cloud/app/main.py).
+        # api_base already includes /api/v1, so we only append the route path.
+        result = self._post("/corpus/contribute", {"patterns": anonymized_patterns})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 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})
# Backend mounts the corpus router under /api/v1 (same prefix as
# telemetry — see cloud/app/main.py).
# api_base already includes /api/v1, so we only append the route path.
result = self._post("/corpus/contribute", {"patterns": anonymized_patterns})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/src/gradata/cloud/sync.py` around lines 242 - 244, The call to
self._post is using a duplicated API prefix ("/api/v1/corpus/contribute") even
though the backend already mounts the router under /api/v1; change the endpoint
passed to self._post in the code that assigns result (the line calling
self._post("/api/v1/corpus/contribute", {"patterns": anonymized_patterns})) to
use the route without the "/api/v1" prefix (e.g. "/corpus/contribute") so the
request path is correct.

Comment on lines +102 to +103
safe_desc = description.replace('"', '\\"')
lines.append(f'description: "{safe_desc}"')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Escape backslashes in YAML description

Line 102 only escapes ". In double-quoted YAML, unescaped \ can create invalid escapes or mutate content (e.g., Windows-style paths). This can break frontmatter parsing.

Suggested fix
-    safe_desc = description.replace('"', '\\"')
+    safe_desc = (
+        description.replace("\\", "\\\\")
+        .replace('"', '\\"')
+        .replace("\n", "\\n")
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/src/gradata/enhancements/skill_export.py` around lines 102 - 103, The
YAML description is only escaping double quotes (safe_desc =
description.replace('"', '\\"')) which leaves backslashes unescaped and can
produce invalid YAML; update the logic in skill_export.py to first escape
backslashes and then escape quotes (e.g., transform description by replacing "\"
with "\\" before replacing '"' with '\"') and use that result in
lines.append(f'description: "{safe_desc}"') so backslashes in Windows paths or
other content are preserved and do not create invalid YAML.

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Whitespace-only explicit descriptions should fall back to auto description

Line 199 treats " " as explicit input and emits an empty frontmatter description. That should fall back to _auto_description(...) to keep a usable skill descriptor.

Suggested fix
-    desc = description.strip() if description else _auto_description(rules, slug)
+    desc = (
+        description.strip()
+        if (description is not None and description.strip())
+        else _auto_description(rules, slug)
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
desc = description.strip() if description else _auto_description(rules, slug)
desc = (
description.strip()
if (description is not None and description.strip())
else _auto_description(rules, slug)
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/src/gradata/enhancements/skill_export.py` at line 199, The current
assignment treats whitespace-only description as explicit and yields an empty
desc; change the logic so you first compute the stripped value and if it's
non-empty use it, otherwise call _auto_description(rules, slug). In other words,
update the desc determination around the variables description, desc, and the
call to _auto_description so that whitespace-only strings fall back to
_auto_description (e.g., check description and description.strip() before
choosing the explicit value).

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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test assertions match the double-prefix bug in sync.py.

When the path in sync_metrics() is corrected to /telemetry/metrics, this test assertion will need to be updated accordingly.

🛠️ Fix after sync.py is corrected
-        assert call_path == "/api/v1/telemetry/metrics"
+        assert call_path == "/telemetry/metrics"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert call_path == "/api/v1/telemetry/metrics"
assert call_path == "/telemetry/metrics"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/tests/test_cloud_sync.py` at line 132, The test in test_cloud_sync.py
asserts call_path == "/api/v1/telemetry/metrics" which mirrors the current
double-prefix bug; after fixing sync_metrics in sync.py to use
"/telemetry/metrics", update the assertion to expect "/telemetry/metrics"
instead—locate the assertion comparing call_path in test_cloud_sync.py and
change the expected string to "/telemetry/metrics" so it matches the corrected
sync_metrics behavior.

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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same adjustment needed for corpus contribution path assertion.

🛠️ Fix after sync.py is corrected
-        assert mock_post.call_args[0][0] == "/api/v1/corpus/contribute"
+        assert mock_post.call_args[0][0] == "/corpus/contribute"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert mock_post.call_args[0][0] == "/api/v1/corpus/contribute"
assert mock_post.call_args[0][0] == "/corpus/contribute"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/tests/test_cloud_sync.py` at line 171, The corpus contribution path
assertion uses the wrong positional index; update the assertion in
tests/test_cloud_sync.py to compare the correct argument from
mock_post.call_args (use the same index used for other path assertions after
fixing sync.py, e.g., mock_post.call_args[0][1] == "/api/v1/corpus/contribute")
so it checks the actual request path sent by the code under test.

Comment on lines +121 to +125
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add regression coverage for YAML escape edge cases

This test currently verifies quotes only. Please also cover backslashes/newlines in descriptions so frontmatter serialization regressions are caught.

Suggested test additions
 class TestExportSkill:
@@
     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_backslashes_and_newlines_in_description_are_escaped(self, tmp_path: Path) -> None:
+        text = export_skill(tmp_path, name="demo", description="Path C:\\new\\skill\nnext line")
+        assert r'description: "Path C:\\new\\skill\nnext line"' in text
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gradata/tests/test_skill_export.py` around lines 121 - 125, Extend the test
coverage for YAML frontmatter escaping by adding cases that exercise backslashes
and newlines in the description string produced by export_skill: call
export_skill(tmp_path, name="demo", description='contains \\ backslash') and
assert the returned text contains the backslash escaped (i.e. the frontmatter
shows a double backslash sequence), and call export_skill(tmp_path, name="demo",
description="line1\nline2") and assert the returned text contains a YAML-safe
escaped newline (e.g. "\n" or the serializer's chosen escaped representation) so
both backslash and newline edge-cases are covered; place these assertions
alongside or as additional tests near
test_double_quotes_in_description_are_escaped and reference the export_skill
helper in your assertions.

@Gradata Gradata merged commit 6867c4e into main May 1, 2026
9 checks passed
@Gradata Gradata deleted the feat/skill-export-m1-clean branch May 1, 2026 15:31
Gradata added a commit that referenced this pull request May 1, 2026
Critical:
- cloud/sync.py: fix double /api/v1 prefix on telemetry + corpus paths

Major:
- cli.py: resolve brain_root once for skill export consistency
- skill_export.py: escape backslashes in YAML descriptions
- skill_export.py: whitespace-only desc falls back to auto
- implicit_feedback.py: negative signals win over approval on conflict
- inject_brain_rules.py: harden MAX_RULES int parse against malformed env

Tests:
- update assertions for corrected /telemetry + /corpus paths
- add regression coverage for YAML backslash/newline/whitespace
- tighten loose assertions in hooks_intelligence

Co-authored-by: Oliver <oliver@spritesai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant