Skip to content
Merged
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
7 changes: 4 additions & 3 deletions observal-server/services/harness/kiro.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ def format_config(self, ctx: ConfigContext) -> dict:
hook_configs = ctx.hook_configs
skill_configs = ctx.skill_configs

# Telemetry via JSONL session push
# Telemetry via JSONL session push. The UUID is resolved through the local lockfile at push time.
agent_id = str(ctx.agent.id)
if platform == "win32":
push_cmd = "python -m observal_cli.hooks.kiro_session_push"
push_cmd = f'set "OBSERVAL_AGENT_ID={agent_id}" && python -m observal_cli.hooks.kiro_session_push'
else:
push_cmd = "python3 -m observal_cli.hooks.kiro_session_push"
push_cmd = f"OBSERVAL_AGENT_ID={agent_id} python3 -m observal_cli.hooks.kiro_session_push"
hooks: dict = {
"userPromptSubmit": [{"command": push_cmd}],
"stop": [{"command": push_cmd}],
Expand Down
51 changes: 3 additions & 48 deletions observal_cli/cmd_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,56 +1113,11 @@ def _patch_claude_code(dry_run: bool) -> bool:


def _patch_kiro(dry_run: bool) -> bool:
"""Install session push hooks into Kiro agent configs."""
"""Kiro hooks are installed per pulled agent so they can carry the agent UUID."""
optic.trace("dry_run={}", dry_run)
from observal_cli.harness_specs.kiro_hooks_spec import build_kiro_hooks

rprint("[cyan]Kiro - session push hooks[/cyan]")

agents_dir = Path.home() / ".kiro" / "agents"
if not agents_dir.is_dir():
rprint(" [dim]No ~/.kiro/agents/ directory - skipping[/dim]")
return False

agent_profiles = list(agents_dir.glob("*.json"))
if not agent_profiles:
rprint(" [dim]No agent configs found[/dim]")
return False

desired_hooks = build_kiro_hooks()
changed = False

for af in agent_profiles:
agent_name = af.stem
try:
data = json.loads(af.read_text())
except (json.JSONDecodeError, OSError):
rprint(f" [yellow]⚠ {agent_name}: could not parse, skipped[/yellow]")
continue

current_hooks = data.get("hooks", {})
updated = False

for event, desired_entries in desired_hooks.items():
existing = current_hooks.get(event, [])
# Remove old Observal hooks, keep non-Observal ones
cleaned = [h for h in existing if not _is_observal_hook_entry(h)]
new_list = cleaned + desired_entries
if new_list != existing:
current_hooks[event] = new_list
updated = True

if updated:
data["hooks"] = current_hooks
if not dry_run:
af.write_text(json.dumps(data, indent=2) + "\n")
verb = "Would update" if dry_run else "Updated"
rprint(f" {verb} {agent_name}")
changed = True
else:
rprint(f" [dim]{agent_name}: already up to date[/dim]")

return changed
rprint(" [dim]Skipped: Kiro telemetry hooks are installed by `observal agent pull` per agent.[/dim]")
return False


def _patch_cursor(dry_run: bool) -> bool:
Expand Down
12 changes: 7 additions & 5 deletions observal_cli/cmd_pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,23 +331,22 @@ def _write_file(path: Path, content: str | dict, *, merge_mcp: bool = False) ->
return "updated" if existed else "created"


def _rewrite_kiro_hooks(content: dict) -> dict:
def _rewrite_kiro_hooks(content: dict, agent_id: str | None = None) -> dict:
"""Rewrite Kiro hook commands to use the current Python interpreter.

The server generates commands with bare 'python3' which won't find
observal_cli when installed in a project-local virtual environment.
"""
optic.trace("content={}", content)
hooks = content.get("hooks")
agent_name = content.get("name")
if not hooks or not agent_name:
if not hooks:
return content

from observal_cli.harness_specs.kiro_hooks_spec import build_kiro_hooks

cfg = config.get_or_exit()
hooks_url = f"{cfg['server_url'].rstrip('/')}/api/v1/telemetry/hooks"
desired_hooks = build_kiro_hooks(hooks_url, agent_name)
desired_hooks = build_kiro_hooks(hooks_url, agent_id=agent_id or "")

# Replace only Observal hooks, preserve any user-added hooks
for event, desired_entries in desired_hooks.items():
Expand Down Expand Up @@ -689,7 +688,10 @@ def pull(
# Rewrite hook commands to use the current Python interpreter
# so they work regardless of which directory Kiro is launched from.
if isinstance(agent_profile.get("content"), dict):
agent_profile["content"] = _rewrite_kiro_hooks(agent_profile["content"])
agent_profile["content"] = _rewrite_kiro_hooks(
agent_profile["content"],
agent_id=str(agent_detail.get("id", resolved)),
)
elif isinstance(agent_profile.get("content"), str):
agent_profile["content"] = _resolve_hook_paths(agent_profile["content"])
# Cursor only reads .cursor/agents/ from the project directory,
Expand Down
10 changes: 5 additions & 5 deletions observal_cli/harness_specs/kiro_hooks_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ def build_kiro_hooks(*args, **kwargs) -> dict:
"""Build the complete hooks dict for a Kiro agent config.

Only 2 events: userPromptSubmit and stop.
Accepts optional (hooks_url, agent_name) for per-agent attribution.
Accepts optional agent_id for per-agent attribution.
"""
agent_name = args[1] if len(args) > 1 else kwargs.get("agent_name", "")
agent_id = kwargs.get("agent_id", "") or (args[2] if len(args) > 2 else "")
cmd = f"{_python_cmd()} -m observal_cli.hooks.kiro_session_push"
if agent_name:
if agent_id:
if sys.platform == "win32":
cmd = f'set "OBSERVAL_AGENT_NAME={agent_name}" && {cmd}'
cmd = f'set "OBSERVAL_AGENT_ID={agent_id}" && {cmd}'
else:
cmd = f"OBSERVAL_AGENT_NAME={agent_name} {cmd}"
cmd = f"OBSERVAL_AGENT_ID={agent_id} {cmd}"
return {
"userPromptSubmit": [{"command": cmd}],
"stop": [{"command": cmd}],
Expand Down
1 change: 1 addition & 0 deletions observal_cli/hooks/kiro_session_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def _run(home: Path | None = None) -> None:
line_count_before=line_count,
new_offset=offset,
cwd=cwd,
harness="kiro",
)
payload_credits["harness"] = "kiro"
payload_credits["total_credits"] = credits
Expand Down
12 changes: 12 additions & 0 deletions observal_cli/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@ def get_agent_for_directory(harness: str, directory: str) -> dict | None:
return None


def get_agent_by_id(agent_id: str, harness: str | None = None) -> dict | None:
"""Find a lockfile agent by UUID, optionally scoped to one harness."""
data = read_lockfile()
for harness_name, harness_section in data.get("harnesses", {}).items():
if harness and harness_name != harness:
continue
for agent in harness_section.get("agents", []):
if agent.get("id") == agent_id:
return agent
return None


def get_all_entries(harness: str | None = None) -> list[dict]:
"""Get all lock file entries, optionally filtered by harness.

Expand Down
48 changes: 36 additions & 12 deletions observal_cli/sessions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def post_lines_chunked(
cwd=cwd,
parent_session_id=parent_session_id,
session_jsonl=session_jsonl,
harness=harness,
)
payload["harness"] = harness
# Only mark final on the last chunk if the hook_event warrants it
Expand Down Expand Up @@ -321,13 +322,14 @@ def build_payload(
cwd: str = "",
parent_session_id: str | None = None,
session_jsonl: Path | None = None,
harness: str = "claude-code",
) -> dict:
"""Construct the JSON body for the ingest endpoint.

Defaults harness telemetry to ``claude-code``; callers override ``payload["harness"]``
for other harnesses.
"""
agent_id, agent_version = _resolve_agent(cwd, lines, session_jsonl)
agent_id, agent_version = _resolve_agent(cwd, lines, session_jsonl, harness=harness)
layer_hash = _get_cached_layer_hash(session_id, cwd)
payload: dict = {
"session_id": session_id,
Expand Down Expand Up @@ -446,24 +448,35 @@ def _maybe_upload_layer_snapshot(
optic.debug("layer snapshot upload skipped: {}", e)


def _resolve_agent(cwd: str, lines: list[str], session_jsonl: Path | None) -> tuple[str | None, str | None]:
"""Resolve agent identity from multiple sources.

Name resolution (first match wins):
1. OBSERVAL_AGENT_NAME env var (Kiro per-agent hook commands)
2. agent-setting line in JSONL (Claude Code embeds active agent)
3. Lock file lookup by cwd + harness
def _resolve_agent(
cwd: str,
lines: list[str],
session_jsonl: Path | None,
harness: str = "claude-code",
) -> tuple[str | None, str | None]:
"""Resolve agent identity from explicit metadata and the lockfile.

Version resolution always comes from the lockfile. Steps 1 and 2 only
provide the agent name; the lockfile is the authoritative source for
the installed version.
Kiro attribution is UUID-only via OBSERVAL_AGENT_ID. The lockfile is the
source of truth for the installed version; cwd must not guess Kiro agents.
"""
import os

env_agent_id = os.environ.get("OBSERVAL_AGENT_ID", "")
if env_agent_id:
lockfile_entry = _lookup_lockfile_agent_by_id(env_agent_id, harness=harness)
if lockfile_entry:
return lockfile_entry.get("id"), lockfile_entry.get("version")
optic.warning("OBSERVAL_AGENT_ID={} not found in lockfile for harness={}", env_agent_id, harness)
return None, None

if harness == "kiro":
optic.debug("Kiro session has no OBSERVAL_AGENT_ID; leaving unattributed")
return None, None

# Resolve agent name from env var or JSONL
agent_name: str | None = None

# 1. Env var (Kiro per-agent hooks embed this)
# 1. Env var for legacy/non-Kiro hooks
env_agent = os.environ.get("OBSERVAL_AGENT_NAME", "")
if env_agent:
agent_name = env_agent
Expand Down Expand Up @@ -494,6 +507,17 @@ def _resolve_agent(cwd: str, lines: list[str], session_jsonl: Path | None) -> tu
return None, None


def _lookup_lockfile_agent_by_id(agent_id: str, harness: str | None = None) -> dict | None:
"""Find a lockfile agent by UUID."""
try:
from observal_cli.lockfile import get_agent_by_id

return get_agent_by_id(agent_id, harness=harness)
except Exception as e:
optic.debug("lockfile agent id lookup failed: {}", e)
return None


def _lookup_lockfile_agent(cwd: str, agent_name: str | None = None) -> dict | None:
"""Find the lockfile agent for a directory and optional agent name."""
try:
Expand Down
4 changes: 4 additions & 0 deletions tests/test_agent_config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,8 @@ def test_win32_hooks_use_session_push(self):
cmds = self._all_hook_commands(cfg)
for cmd in cmds:
assert "kiro_session_push" in cmd
assert f"OBSERVAL_AGENT_ID={agent.id}" in cmd
assert "OBSERVAL_AGENT_NAME" not in cmd

def test_hooks_omit_model_flag(self):
"""Model is detected from Kiro SQLite at runtime, not baked into hook commands."""
Expand Down Expand Up @@ -865,6 +867,8 @@ def test_unix_hooks_use_kiro_session_push(self):
hooks = cfg["agent_profile"]["content"]["hooks"]
cmd = hooks["userPromptSubmit"][0]["command"]
assert "python3 -m observal_cli.hooks.kiro_session_push" in cmd
assert f"OBSERVAL_AGENT_ID={agent.id}" in cmd
assert "OBSERVAL_AGENT_NAME" not in cmd
assert "cat |" not in cmd
assert "sed " not in cmd
assert "curl" not in cmd
Expand Down
4 changes: 3 additions & 1 deletion tests/test_pull_and_agent_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,12 @@ def test_rewrites_hook_python_path(self, tmp_path: Path):
assert result.exit_code == 0, result.output
agent = tmp_path / ".kiro" / "agents" / "my-agent.json"
data = json.loads(agent.read_text())
# All hook commands should use sys.executable, not bare python3
# All hook commands should use sys.executable and the pulled agent UUID.
for event, entries in data["hooks"].items():
for h in entries:
assert sys.executable in h["command"], f"{event} hook missing sys.executable: {h['command']}"
assert "OBSERVAL_AGENT_ID=abc123" in h["command"]
assert "OBSERVAL_AGENT_NAME" not in h["command"]
assert not h["command"].startswith("python3 ")


Expand Down
38 changes: 38 additions & 0 deletions tests/test_resolve_agent_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,44 @@ def _make_lockfile_entry(
class TestResolveAgentVersionFromLockfile:
"""Verify that _resolve_agent enriches agent name with version from lockfile."""

def test_env_agent_id_gets_version_from_lockfile(self):
"""Kiro per-agent hooks pass only the Observal agent UUID."""
entry = _make_lockfile_entry(name="my-agent", agent_id="uuid-123", version="1.2.0")

with (
patch.dict("os.environ", {"OBSERVAL_AGENT_ID": "uuid-123"}, clear=True),
patch("observal_cli.sessions.base._lookup_lockfile_agent_by_id", return_value=entry),
):
agent_id, agent_version = _resolve_agent("", [], None, harness="kiro")

assert agent_id == "uuid-123"
assert agent_version == "1.2.0"

def test_env_agent_id_missing_lockfile_returns_unattributed(self, tmp_path):
"""Unknown Kiro UUIDs must not fall back to cwd guesses."""
with (
patch.dict("os.environ", {"OBSERVAL_AGENT_ID": "missing-uuid"}, clear=True),
patch("observal_cli.sessions.base._lookup_lockfile_agent_by_id", return_value=None),
patch("observal_cli.sessions.base._lookup_lockfile_agent") as cwd_lookup,
):
agent_id, agent_version = _resolve_agent(str(tmp_path), [], None, harness="kiro")

assert agent_id is None
assert agent_version is None
cwd_lookup.assert_not_called()

def test_kiro_without_env_agent_id_returns_unattributed(self, tmp_path):
"""Kiro has no JSONL agent field, so missing UUID means no attribution."""
with (
patch.dict("os.environ", {}, clear=True),
patch("observal_cli.sessions.base._lookup_lockfile_agent") as cwd_lookup,
):
agent_id, agent_version = _resolve_agent(str(tmp_path), [], None, harness="kiro")

assert agent_id is None
assert agent_version is None
cwd_lookup.assert_not_called()

def test_env_var_gets_version_from_lockfile(self, tmp_path):
"""When OBSERVAL_AGENT_NAME is set, version should come from lockfile."""
entry = _make_lockfile_entry(name="my-agent", agent_id="uuid-123", version="1.2.0", directory=str(tmp_path))
Expand Down
Loading