Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gradata/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ gradata install --agent opencode
gradata install --agent all
```

Install attempts are measured locally in `<gradata-config-dir>/install_measurements.jsonl` with per-agent `status`, `action`, and `failure_kind` (`none`, `code_failure`, or `docs_friction`) for Claude Code, Codex, Hermes, and Cursor.

Once installed, Gradata recalls relevant behavioral rules before tool use. You can also call recall directly:

```bash
Expand Down
11 changes: 11 additions & 0 deletions Gradata/docs/getting-started/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ gradata install --agent claude-code --brain ./my-brain
Supported targets are `claude-code`, `codex`, `gemini`, `cursor`, `hermes`,
`opencode`, and `all`.

Each `gradata install --agent ...` attempt appends a structured JSONL row to
`<gradata-config-dir>/install_measurements.jsonl` (usually
`~/.gradata/install_measurements.jsonl`, or `$XDG_CONFIG_HOME/gradata/` when set).
The row records `agent`, `status`, `action`, and `failure_kind` so launch metrics
can measure install success separately for Claude Code, Codex, Hermes, and Cursor:

- `failure_kind: none` — install succeeded or was already present.
- `failure_kind: code_failure` — the adapter ran but failed to write/parse config.
- `failure_kind: docs_friction` — `--agent all` could not detect a measured host
config, so docs/onboarding must explain how to create or select that host.

## Verify

```bash
Expand Down
77 changes: 77 additions & 0 deletions Gradata/src/gradata/_install_measurement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Local install-success measurement for Gradata agent integrations.

Records every `gradata install --agent ...` attempt to a user-level JSONL file
so distribution experiments can separate successful installs, code failures, and
docs/setup friction across the supported AI coding CLIs.
"""

from __future__ import annotations

import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Final, Literal

from gradata._config_paths import get_config_file
from gradata.hooks.adapters._base import InstallResult

MEASURED_INSTALL_AGENTS: Final[tuple[str, ...]] = (
"claude-code",
"codex",
"hermes",
"cursor",
)
INSTALL_MEASUREMENTS_FILE: Final[str] = "install_measurements.jsonl"
FailureKind = Literal["none", "code_failure", "docs_friction"]


def measurement_path() -> Path:
return get_config_file(INSTALL_MEASUREMENTS_FILE)


def classify_result(result: InstallResult) -> FailureKind:
if result.action != "failed":
return "none"
return "code_failure"


def append_measurement(
result: InstallResult,
*,
brain_dir: Path,
source: str = "gradata install --agent",
failure_kind: FailureKind | None = None,
) -> dict[str, object]:
status = "success" if result.action != "failed" else "failure"
payload: dict[str, object] = {
"ts": datetime.now(UTC).isoformat(),
"source": source,
"agent": result.agent,
"status": status,
"action": result.action,
"failure_kind": failure_kind or classify_result(result),
"config_path": str(result.config_path),
"brain_dir": str(brain_dir),
"message": result.message,
}
path = measurement_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload, sort_keys=True) + "\n")
Comment on lines +59 to +60

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 | ⚡ Quick win

Use atomic-write helper for JSONL append to prevent partial-write corruption.

The coding guidelines require: "Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes." While this is append-only JSONL, a crash during the write could leave a partial line that breaks downstream parsing. Consider using an atomic-write pattern (write to temp file, then rename) to ensure each measurement is fully written or not at all.

🛡️ Suggested atomic-write pattern
+import tempfile
+import shutil
+
 def append_measurement(
     result: InstallResult,
     *,
     brain_dir: Path,
     source: str = "gradata install --agent",
     failure_kind: FailureKind | None = None,
 ) -> dict[str, object]:
     status = "success" if result.action != "failed" else "failure"
     payload: dict[str, object] = {
         "ts": datetime.now(UTC).isoformat(),
         "source": source,
         "agent": result.agent,
         "status": status,
         "action": result.action,
         "failure_kind": failure_kind or classify_result(result),
         "config_path": str(result.config_path),
         "brain_dir": str(brain_dir),
         "message": result.message,
     }
     path = measurement_path()
     path.parent.mkdir(parents=True, exist_ok=True)
-    with path.open("a", encoding="utf-8") as f:
-        f.write(json.dumps(payload, sort_keys=True) + "\n")
+    # Atomic append: write to temp, then move
+    tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, text=True)
+    try:
+        with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
+            # Read existing content if file exists
+            if path.exists():
+                f.write(path.read_text(encoding="utf-8"))
+            f.write(json.dumps(payload, sort_keys=True) + "\n")
+        shutil.move(tmp_path, path)
+    except Exception:
+        Path(tmp_path).unlink(missing_ok=True)
+        raise
     return payload

Alternatively, use a project-standard atomic-write helper if one exists in the codebase.

📝 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
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload, sort_keys=True) + "\n")
import tempfile
import shutil
import os
def append_measurement(
result: InstallResult,
*,
brain_dir: Path,
source: str = "gradata install --agent",
failure_kind: FailureKind | None = None,
) -> dict[str, object]:
status = "success" if result.action != "failed" else "failure"
payload: dict[str, object] = {
"ts": datetime.now(UTC).isoformat(),
"source": source,
"agent": result.agent,
"status": status,
"action": result.action,
"failure_kind": failure_kind or classify_result(result),
"config_path": str(result.config_path),
"brain_dir": str(brain_dir),
"message": result.message,
}
path = measurement_path()
path.parent.mkdir(parents=True, exist_ok=True)
# Atomic append: write to temp, then move
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, text=True)
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
# Read existing content if file exists
if path.exists():
f.write(path.read_text(encoding="utf-8"))
f.write(json.dumps(payload, sort_keys=True) + "\n")
shutil.move(tmp_path, path)
except Exception:
Path(tmp_path).unlink(missing_ok=True)
raise
return payload
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/src/gradata/_install_measurement.py` around lines 59 - 60, The append
currently using path.open("a") can leave a partial JSONL line; change it to use
the project atomic-write helper (or implement the atomic-write pattern) so each
measurement is written atomically: create a temp file in the same directory,
write the original file contents plus the new json.dumps(payload,
sort_keys=True) + "\n" into the temp, fsync the temp, close it and then
os.replace the temp into place (or call the atomic helper) instead of using
path.open("a"); update the code locations referencing path.open and payload to
use this atomic-write flow.

Source: Coding guidelines

return payload


def append_docs_friction(
agent: str,
*,
config_path: Path,
brain_dir: Path,
message: str,
source: str = "gradata install --agent all",
) -> dict[str, object]:
return append_measurement(
InstallResult(agent, config_path, "failed", message),
brain_dir=brain_dir,
source=source,
failure_kind="docs_friction",
)
26 changes: 24 additions & 2 deletions Gradata/src/gradata/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,11 +455,28 @@ def _cmd_install_systemd(args) -> None:


def _cmd_install_agent(args) -> None:
from gradata.hooks.adapters._base import AGENTS, adapter_config_path, get_adapter
from gradata._install_measurement import (
MEASURED_INSTALL_AGENTS,
append_docs_friction,
append_measurement,
)
from gradata.hooks.adapters._base import AGENTS, InstallResult, adapter_config_path, get_adapter

agent = args.agent
brain_dir = _resolve_agent_brain_root(args)
agents = [a for a in AGENTS if adapter_config_path(a).exists()] if agent == "all" else [agent]
if agent == "all":
agents = [a for a in AGENTS if adapter_config_path(a).exists()]
for measured_agent in MEASURED_INSTALL_AGENTS:
measured_config_path = adapter_config_path(measured_agent)
if not measured_config_path.exists():
append_docs_friction(
measured_agent,
config_path=measured_config_path,
brain_dir=brain_dir,
message="agent config not detected during --agent all discovery",
)
else:
agents = [agent]

if not agents:
print("No agent config files detected.")
Expand All @@ -477,12 +494,17 @@ def _cmd_install_agent(args) -> None:
result = adapter.install(brain_dir, config_path)
except Exception as exc:
print(f"✗ {name} → unknown (failed: {exc})")
append_measurement(
InstallResult(name, adapter_config_path(name), "failed", str(exc)),
brain_dir=brain_dir,
)
had_failure = True
continue
marker = "✓" if result.action != "failed" else "✗"
if result.action == "failed":
had_failure = True
print(f"{marker} {result.agent} → {result.config_path} ({result.action})")
append_measurement(result, brain_dir=brain_dir)

# Record the install in ~/.gradata/install_manifest.json so that
# `gradata uninstall --agent <host>` can safely reverse it later
Expand Down
57 changes: 57 additions & 0 deletions Gradata/tests/test_cli_install_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,60 @@ def test_cli_install_agent_verify_flag_skips_on_failed_install(tmp_path: Path) -
suffix = line.lstrip()
if suffix.startswith("✓ verify:") or suffix.startswith("✗ verify") or suffix.startswith("⚠ verify"):
raise AssertionError(f"unexpected verify line on failed install: {line}")


def _read_install_measurements(tmp_path: Path) -> list[dict[str, object]]:
import json

path = tmp_path / ".config" / "gradata" / "install_measurements.jsonl"
assert path.exists()
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]


def test_cli_install_agent_records_success_measurement_for_measured_cli(tmp_path: Path) -> None:
brain = tmp_path / "brain"
brain.mkdir()

result = _run_cli(tmp_path, "install", "--agent", "codex", "--brain", str(brain))

assert result.returncode == 0, result.stderr
measurements = _read_install_measurements(tmp_path)
assert measurements[-1]["agent"] == "codex"
assert measurements[-1]["status"] == "success"
assert measurements[-1]["failure_kind"] == "none"
assert measurements[-1]["action"] == "added"


def test_cli_install_agent_records_code_failure_measurement(tmp_path: Path) -> None:
brain = tmp_path / "brain"
brain.mkdir()
bad_config_dir = tmp_path / ".claude"
bad_config_dir.mkdir(parents=True)
(bad_config_dir / "settings.json").write_text("{{{bad json", encoding="utf-8")

result = _run_cli(tmp_path, "install", "--agent", "claude-code", "--brain", str(brain))

assert result.returncode != 0
measurements = _read_install_measurements(tmp_path)
assert measurements[-1]["agent"] == "claude-code"
assert measurements[-1]["status"] == "failure"
assert measurements[-1]["failure_kind"] == "code_failure"


def test_cli_install_agent_all_records_docs_friction_for_missing_measured_cli_configs(
tmp_path: Path,
) -> None:
brain = tmp_path / "brain"
brain.mkdir()
(tmp_path / ".codex").mkdir()
(tmp_path / ".codex" / "config.toml").write_text("", encoding="utf-8")

result = _run_cli(tmp_path, "install", "--agent", "all", "--brain", str(brain))

assert result.returncode == 0, result.stderr
measurements = _read_install_measurements(tmp_path)
by_agent = {m["agent"]: m for m in measurements}
assert by_agent["codex"]["status"] == "success"
assert by_agent["claude-code"]["failure_kind"] == "docs_friction"
assert by_agent["hermes"]["failure_kind"] == "docs_friction"
assert by_agent["cursor"]["failure_kind"] == "docs_friction"
Loading