diff --git a/observal-server/services/harness/kiro.py b/observal-server/services/harness/kiro.py index 21e966850..4104c2e24 100644 --- a/observal-server/services/harness/kiro.py +++ b/observal-server/services/harness/kiro.py @@ -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}], diff --git a/observal_cli/cmd_doctor.py b/observal_cli/cmd_doctor.py index 419860ef2..8d8af7f07 100644 --- a/observal_cli/cmd_doctor.py +++ b/observal_cli/cmd_doctor.py @@ -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: diff --git a/observal_cli/cmd_pull.py b/observal_cli/cmd_pull.py index 7ad7dc394..3caf77791 100644 --- a/observal_cli/cmd_pull.py +++ b/observal_cli/cmd_pull.py @@ -331,7 +331,7 @@ 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 @@ -339,15 +339,14 @@ def _rewrite_kiro_hooks(content: dict) -> dict: """ 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(): @@ -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, diff --git a/observal_cli/harness_specs/kiro_hooks_spec.py b/observal_cli/harness_specs/kiro_hooks_spec.py index d460bdfc9..543d4435f 100644 --- a/observal_cli/harness_specs/kiro_hooks_spec.py +++ b/observal_cli/harness_specs/kiro_hooks_spec.py @@ -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}], diff --git a/observal_cli/hooks/kiro_session_push.py b/observal_cli/hooks/kiro_session_push.py index f9d24ad98..39107982b 100644 --- a/observal_cli/hooks/kiro_session_push.py +++ b/observal_cli/hooks/kiro_session_push.py @@ -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 diff --git a/observal_cli/lockfile.py b/observal_cli/lockfile.py index 941c6a760..7db0862e1 100644 --- a/observal_cli/lockfile.py +++ b/observal_cli/lockfile.py @@ -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. diff --git a/observal_cli/sessions/base.py b/observal_cli/sessions/base.py index 3cef8523d..938562996 100644 --- a/observal_cli/sessions/base.py +++ b/observal_cli/sessions/base.py @@ -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 @@ -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, @@ -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 @@ -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: diff --git a/tests/test_agent_config_generator.py b/tests/test_agent_config_generator.py index c9cc5e9af..c9141b5a3 100644 --- a/tests/test_agent_config_generator.py +++ b/tests/test_agent_config_generator.py @@ -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.""" @@ -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 diff --git a/tests/test_pull_and_agent_cli.py b/tests/test_pull_and_agent_cli.py index 29e067241..9203a6c49 100644 --- a/tests/test_pull_and_agent_cli.py +++ b/tests/test_pull_and_agent_cli.py @@ -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 ") diff --git a/tests/test_resolve_agent_version.py b/tests/test_resolve_agent_version.py index 9b18f7729..41cd91970 100644 --- a/tests/test_resolve_agent_version.py +++ b/tests/test_resolve_agent_version.py @@ -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))