diff --git a/Gradata/README.md b/Gradata/README.md index 0f4d1dd5..ae27872b 100644 --- a/Gradata/README.md +++ b/Gradata/README.md @@ -118,6 +118,8 @@ gradata install --agent opencode gradata install --agent all ``` +Install attempts are measured locally in `/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 diff --git a/Gradata/docs/getting-started/install.md b/Gradata/docs/getting-started/install.md index 0189040e..faedccc5 100644 --- a/Gradata/docs/getting-started/install.md +++ b/Gradata/docs/getting-started/install.md @@ -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 +`/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 diff --git a/Gradata/src/gradata/_install_measurement.py b/Gradata/src/gradata/_install_measurement.py new file mode 100644 index 00000000..9210ec86 --- /dev/null +++ b/Gradata/src/gradata/_install_measurement.py @@ -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") + 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", + ) diff --git a/Gradata/src/gradata/cli.py b/Gradata/src/gradata/cli.py index 34fe147d..48e99c96 100644 --- a/Gradata/src/gradata/cli.py +++ b/Gradata/src/gradata/cli.py @@ -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.") @@ -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 ` can safely reverse it later diff --git a/Gradata/tests/test_cli_install_agent.py b/Gradata/tests/test_cli_install_agent.py index 307d07e6..5fe0b8f0 100644 --- a/Gradata/tests/test_cli_install_agent.py +++ b/Gradata/tests/test_cli_install_agent.py @@ -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"