From 05b428cdb55d85cda270004f55b43f510a8d34be Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Sat, 26 Sep 2026 17:31:16 +0100 Subject: [PATCH 1/5] test(kiro): RED -- StudyLoop's ACP sessions run the learner's default Kiro agent Web ACP sessions launch `kiro-cli acp` with no `--agent`, so each one runs under whatever the learner's default Kiro agent is. Until today that was Kiro's built-in default (its own system prompt, the learner's personal steering and skills, every server in the global mcp.json), because kiro-cli 2.24.0 reserves `kiro_default` and ignored the learner's kiro_default.json. Renaming that file made the learner's personal agent the default -- and so, silently, the mentor's configuration. Owner steer (2026-09-26): the test for a valid StudyLoop agent is done with a dedicated agent named `studyloop`, created at install time and used as required. These tests pin that agent (persona-neutral, StudyLoop's two MCP servers only, the persona agents' hook and denylist, no pre-approvals -- the ACP path had none under the built-in default and decision 2 widens no grant), its install link, manifest entry and nightly check, the ACP argv, and a doctor check proving kiro-cli validates and lists it. 17 fail for the stated reasons; the grok argv guard passes. The two pyright ignores on the not-yet-defined doctor check are RED-only and go at GREEN. --- .../tests/test_kiro_studyloop_agent.py | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 packages/studyloop/tests/test_kiro_studyloop_agent.py diff --git a/packages/studyloop/tests/test_kiro_studyloop_agent.py b/packages/studyloop/tests/test_kiro_studyloop_agent.py new file mode 100644 index 00000000..65c26142 --- /dev/null +++ b/packages/studyloop/tests/test_kiro_studyloop_agent.py @@ -0,0 +1,261 @@ +"""StudyLoop's own Kiro agent, ``agents/kiro/studyloop.json``. + +Web ACP sessions launched ``kiro-cli acp`` with no ``--agent``, so every one +ran under whatever the learner's *default* Kiro agent happened to be. Until +2026-09-26 that was Kiro's built-in default -- its own system prompt, all of +the learner's personal steering and skills, and every MCP server in their +global ``mcp.json`` -- because kiro-cli 2.24.0 reserves the name +``kiro_default`` and ignored the learner's ``~/.kiro/agents/kiro_default.json``. +Renaming that file made the learner's personal agent the default, and so, +silently, the configuration of StudyLoop's mentor. A learner's own agent is +theirs to change; the mentor must not change with it. + +So StudyLoop installs a dedicated agent named ``studyloop`` at install time, +names it on every Kiro ACP launch, and ``studyloop doctor`` proves kiro-cli +can load it (owner steer, 2026-09-26). The agent carries no persona: the +persona is ACP's invisible first turn, and a second one here would compete +with it. It contributes only what StudyLoop owns -- its two MCP servers, the +session-export stop hook and the command denylist the persona agents carry. +It pre-approves nothing: the ACP path pre-approved nothing under the built-in +default either (``allowedTools: []``), and owner decision 2 (#34) is that no +grant is widened without a decision. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace +from typing import TYPE_CHECKING + +from studyloop import installers + +if TYPE_CHECKING: + import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEFINITION = REPO_ROOT / "agents/kiro/studyloop.json" +MENTOR = REPO_ROOT / "agents/kiro/study-mentor.json" + + +def _definition() -> dict: + return json.loads(DEFINITION.read_text(encoding="utf-8")) + + +def _mentor() -> dict: + return json.loads(MENTOR.read_text(encoding="utf-8")) + + +class TestTheDefinition: + def test_is_named_studyloop(self) -> None: + assert _definition()["name"] == "studyloop" + + def test_carries_no_persona(self) -> None: + definition = _definition() + assert "prompt" not in definition + assert definition.get("resources", []) == [] + + def test_reaches_exactly_the_studyloop_servers(self) -> None: + definition = _definition() + mentor = _mentor() + assert definition["tools"] == ["@builtin", "@studyloop", "@session-db"] + assert definition["mcpServers"] == { + name: mentor["mcpServers"][name] for name in ("studyloop", "session-db") + } + # Nothing from the learner's global mcp.json rides along. + assert not definition.get("includeMcpJson") + assert not definition.get("useLegacyMcpJson") + + def test_pre_approves_nothing(self) -> None: + assert _definition()["allowedTools"] == [] + + def test_carries_the_persona_agents_hook_and_denylist(self) -> None: + definition = _definition() + mentor = _mentor() + assert definition["hooks"] == mentor["hooks"] + assert definition["toolsSettings"] == mentor["toolsSettings"] + + +class TestCreatedAtInstallTime: + def test_the_installer_links_it(self) -> None: + links = {(spec.source, spec.target) for spec in installers._TOOL_LINKS["kiro"]} + assert ( + "agents/kiro/studyloop.json", + str(installers._HOME / ".kiro/agents/studyloop.json"), + ) in links + + def test_install_places_it(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + target = tmp_path / ".kiro/agents/studyloop.json" + # The real table's own entry, re-targeted into tmp_path: without the + # entry nothing is linked, so this fails for the reason it names. + specs = [ + spec + for spec in installers._TOOL_LINKS["kiro"] + if spec.source == "agents/kiro/studyloop.json" + ] + monkeypatch.setattr( + installers, + "_TOOL_LINKS", + {"kiro": tuple(installers.LinkSpec(spec.source, str(target)) for spec in specs)}, + ) + monkeypatch.setattr(installers, "_SHARED_LINKS", ()) + monkeypatch.setattr(installers, "XTILES_SKILL_LINKS", {}) + monkeypatch.setattr(installers, "SESSION_MEMORY_SKILL_LINKS", {}) + # Only the link step is under test; nothing else may reach a real home. + monkeypatch.setattr(installers, "install_session_db_mandate", lambda *_a, **_k: {}) + monkeypatch.setattr(installers, "register_mcp_servers", lambda *_a, **_k: {}) + + installers.install_agent_definitions(REPO_ROOT, tools=["kiro"]) + + assert target.is_symlink() + assert target.resolve() == DEFINITION.resolve() + assert json.loads(target.read_text(encoding="utf-8"))["name"] == "studyloop" + + def test_the_manifest_tracks_it(self) -> None: + manifest = json.loads((REPO_ROOT / "agents/manifest.json").read_text(encoding="utf-8")) + entry = manifest["agents"]["kiro/studyloop.json"] + assert entry["hash"] == hashlib.sha256(DEFINITION.read_bytes()).hexdigest()[:16] + regenerator = (REPO_ROOT / "scripts/update-agent-manifest.py").read_text(encoding="utf-8") + assert '"kiro/studyloop.json"' in regenerator + + def test_the_nightly_install_job_checks_it_landed(self) -> None: + workflow = (REPO_ROOT / ".github/workflows/nightly-install.yml").read_text(encoding="utf-8") + assert 'test -e "$HOME/.kiro/agents/studyloop.json"' in workflow + + +class TestTheAcpLaunchNamesIt: + @staticmethod + def _argv(agent: str, monkeypatch: pytest.MonkeyPatch) -> list[str]: + import studyloop + from studyloop.session.transports import acp as acp_module + from studyloop.web.routes.session import _transport + + captured: dict = {} + + class _Capture: + def __init__(self, *, resolve_binary, build_argv) -> None: + captured["build_argv"] = build_argv + + monkeypatch.setattr(acp_module, "ACPTransport", _Capture) + monkeypatch.setattr(studyloop, "test_hatch_env", lambda _name: None) + config = SimpleNamespace(agent=agent) + _transport._build_acp_transport(config)() + return captured["build_argv"](config) + + def test_kiro_runs_the_studyloop_agent(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert self._argv("kiro", monkeypatch) == ["kiro-cli", "acp", "--agent", "studyloop"] + + def test_grok_launch_is_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert self._argv("grok", monkeypatch) == ["grok", "agent", "stdio"] + + +# ``kiro-cli agent list`` colours its scope column even when piped. +_LISTING = ( + "* default-plus Global \n" + " studyloop Global \n" + " kiro_default \x1b[38;5;244m(Built-in)\x1b[0m Default agent\n" +) +_SHADOWED = " studyloop \x1b[38;5;244m(Built-in)\x1b[0m Default agent\n" + + +class TestDoctorProvesItLoads: + """The validity test runs against StudyLoop's own agent, never the learner's default.""" + + @staticmethod + def _run( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + installed: bool = True, + kiro: bool = True, + validate: tuple[int, str] = (0, ""), + listing: str = _LISTING, + ) -> tuple[list, list[list[str]]]: + from studyloop.doctor import agents as doctor_agents + + monkeypatch.setenv("HOME", str(tmp_path)) + agent_file = tmp_path / ".kiro/agents/studyloop.json" + if installed: + agent_file.parent.mkdir(parents=True) + agent_file.write_text('{"name": "studyloop"}\n', encoding="utf-8") + + calls: list[list[str]] = [] + + def _run(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess: + calls.append(list(argv)) + if argv[1:3] == ["agent", "validate"]: + assert argv[3:] == ["--path", str(agent_file)] + code, err = validate + return subprocess.CompletedProcess(argv, code, "", err) + if argv[1:3] == ["agent", "list"]: + return subprocess.CompletedProcess(argv, 0, listing, "") + raise AssertionError(f"unexpected command {argv}") + + monkeypatch.setattr( + doctor_agents.shutil, + "which", + lambda name: "/opt/bin/kiro-cli" if kiro and name == "kiro-cli" else None, + ) + monkeypatch.setattr(doctor_agents.subprocess, "run", _run) + return doctor_agents.check_kiro_studyloop_agent(), calls # pyright: ignore[reportAttributeAccessIssue] # RED + + def test_passes_when_kiro_validates_and_lists_it( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + results, calls = self._run(monkeypatch, tmp_path) + assert [(r.name, r.status) for r in results] == [("agent_kiro_studyloop_loads", "pass")] + assert [c[1:3] for c in calls] == [["agent", "validate"], ["agent", "list"]] + + def test_warns_when_kiro_rejects_it( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + results, _ = self._run( + monkeypatch, tmp_path, validate=(1, "error: unknown field `hookz`\n") + ) + (result,) = results + assert result.status == "warn" + assert "unknown field `hookz`" in result.message + assert result.fix_auto is False + + def test_warns_when_a_built_in_shadows_it( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + results, _ = self._run(monkeypatch, tmp_path, listing=_SHADOWED) + (result,) = results + assert result.status == "warn" + assert "built-in" in result.message + assert result.fix_auto is False + + def test_warns_when_kiro_does_not_list_it( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + listing = "* default-plus Global \n" + results, _ = self._run(monkeypatch, tmp_path, listing=listing) + (result,) = results + assert result.status == "warn" + assert "does not list" in result.message + + def test_warns_and_auto_fixes_when_not_installed( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + results, calls = self._run(monkeypatch, tmp_path, installed=False) + (result,) = results + assert result.status == "warn" + assert result.fix_auto is True + assert "studyloop install agents" in result.fix + assert calls == [] + + def test_is_silent_without_kiro(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + results, calls = self._run(monkeypatch, tmp_path, kiro=False) + assert results == [] + assert calls == [] + + def test_is_registered_with_doctor(self) -> None: + from studyloop.cli._doctor import _get_registry + from studyloop.doctor.agents import ( + check_kiro_studyloop_agent, # pyright: ignore[reportAttributeAccessIssue] # RED + ) + + assert ("agents", check_kiro_studyloop_agent) in _get_registry()._checkers From 711459c1e2d5ba36a98d8e1becb33223535071f9 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Sat, 26 Sep 2026 17:54:52 +0100 Subject: [PATCH 2/5] feat(kiro): web ACP sessions run StudyLoop's own `studyloop` agent `studyloop install agents` now links agents/kiro/studyloop.json to ~/.kiro/agents/studyloop.json, and every Kiro ACP launch is `kiro-cli acp --agent studyloop`, so the learner's default Kiro agent -- whatever prompt, steering, skills or MCP servers it loads -- no longer configures a StudyLoop session (owner steer, 2026-09-26). The agent is persona-neutral: the persona is ACP's invisible first turn, and a second one here would compete with it. It reaches only StudyLoop's `studyloop` and `session-db` servers (entries copied from study-mentor.json) and carries the persona agents' session-export stop hook and execute_bash denylist verbatim. `allowedTools` is empty: under Kiro's built-in default the ACP path pre-approved nothing (read from `kiro-cli agent create --from kiro_default`), and decision 2 (#34) widens no grant without the owner. `studyloop doctor` gains agent_kiro_studyloop_loads, which asks kiro-cli itself: `agent validate --path` must accept the file, and `agent list` must list `studyloop` with no built-in of the same name shadowing it -- the way 2.24.0's reserved `kiro_default` silently ignored a learner's own file. A missing install warns with fix_auto, so `doctor --fix` reinstalls it. The manifest entry was added in place (every other entry's date unchanged) and the regenerator tracks it; the nightly install job checks it landed; the live Kiro ACP test skips with the fix when the agent is not installed. The install contract's definition set covers it. Two RED-only pyright ignores removed; one RED assertion named CheckResult.fix, the field is fix_hint. .secrets.baseline refreshed by a whole-repo scan with the hook's own detect-secrets 1.5.0: 72 -> 72 result files, the one change is the new manifest hash plus the four-line shift of the entries below it. Touched suites 231/231; full suite 7377 passed, failing ids minus the committed environmental set = the known agent-session-tools sqlite3 timeout. --- .github/workflows/nightly-install.yml | 1 + .secrets.baseline | 31 ++- agents/kiro/studyloop.json | 220 ++++++++++++++++++ agents/manifest.json | 4 + .../studyloop/src/studyloop/adapters/kiro.py | 5 + .../studyloop/src/studyloop/cli/_doctor.py | 4 + .../studyloop/src/studyloop/doctor/agents.py | 129 ++++++++++ .../studyloop/src/studyloop/installers.py | 4 + .../web/routes/session/_transport.py | 10 +- .../tests/test_install_agent_contracts.py | 1 + .../tests/test_kiro_studyloop_agent.py | 8 +- .../tests/test_web_acp_dogfood_kiro.py | 8 +- scripts/update-agent-manifest.py | 1 + 13 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 agents/kiro/studyloop.json diff --git a/.github/workflows/nightly-install.yml b/.github/workflows/nightly-install.yml index c897c926..0c8a6ab1 100644 --- a/.github/workflows/nightly-install.yml +++ b/.github/workflows/nightly-install.yml @@ -114,6 +114,7 @@ jobs: run: | test -e "$HOME/.kiro/agents/study-mentor.json" test -e "$HOME/.kiro/agents/study-plan-architect.json" + test -e "$HOME/.kiro/agents/studyloop.json" test -e "$HOME/.claude/agents/socratic-mentor.md" test -e "$HOME/.claude/agents/study-plan-architect.md" test -e "$HOME/.pi/agent/AGENTS.md" diff --git a/.secrets.baseline b/.secrets.baseline index f5ee2dce..aff498a5 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -169,82 +169,89 @@ "is_verified": false, "line_number": 21 }, + { + "type": "Hex High Entropy String", + "filename": "agents/manifest.json", + "hashed_secret": "4710fab316f69e04956ee7d961d5e1a33522d5ad", + "is_verified": false, + "line_number": 25 + }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "57dfc788ed2e518411553353dc67065fcf7961ed", "is_verified": false, - "line_number": 29 + "line_number": 33 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "7137f7bb0acc961407878db7c3545234a17a6d19", "is_verified": false, - "line_number": 37 + "line_number": 41 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "4b6565ab5e1ec609e05553d003cf338070d81836", "is_verified": false, - "line_number": 41 + "line_number": 45 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "e7b7bf3d1502c08eed0f1507a6dfba255dd6e1f8", "is_verified": false, - "line_number": 45 + "line_number": 49 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "e7fca37cfc3b1ffc8f2d19e6d42cb4f726f5e091", "is_verified": false, - "line_number": 57 + "line_number": 61 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "55b5135330fd4896f3cc433497080730bcec8403", "is_verified": false, - "line_number": 61 + "line_number": 65 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "4e0abd652b07617b2ac184d46f040a7cabdf4a27", "is_verified": false, - "line_number": 69 + "line_number": 73 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "b37f8d4aca0f85435b52e167ee9f09280a3f9593", "is_verified": false, - "line_number": 73 + "line_number": 77 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "68c9c672f0270cc052ebe5fbd253d7fdb58b5522", "is_verified": false, - "line_number": 77 + "line_number": 81 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "c5500010d104bbe79247185dc8f18325d26bce2b", "is_verified": false, - "line_number": 81 + "line_number": 85 }, { "type": "Hex High Entropy String", "filename": "agents/manifest.json", "hashed_secret": "ee12a35a3779f33af762a202e2f8dfa4781ae562", "is_verified": false, - "line_number": 89 + "line_number": 93 } ], "agents/mcp/README.md": [ @@ -2056,5 +2063,5 @@ } ] }, - "generated_at": "2026-09-23T10:41:10Z" + "generated_at": "2026-09-26T16:54:30Z" } diff --git a/agents/kiro/studyloop.json b/agents/kiro/studyloop.json new file mode 100644 index 00000000..a604ffb3 --- /dev/null +++ b/agents/kiro/studyloop.json @@ -0,0 +1,220 @@ +{ + "name": "studyloop", + "description": "StudyLoop's own agent for web (ACP) study and planning sessions: StudyLoop's MCP servers and nothing of the learner's. It carries no persona; the session sends one. Installed by studyloop install agents.", + "tools": [ + "@builtin", + "@studyloop", + "@session-db" + ], + "mcpServers": { + "studyloop": { + "command": "studyloop-mcp", + "args": [] + }, + "session-db": { + "command": "session-db-mcp", + "args": [] + } + }, + "hooks": { + "stop": [ + { + "command": "$HOME/.local/bin/session-export --kiro-only >>$HOME/.config/studyloop/export-hook.log 2>&1 || { rc=$?; echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) session-export --kiro-only FAILED exit=$rc\" >>$HOME/.config/studyloop/export-hook.log; }", + "description": "studyloop:session-export-hook" + } + ] + }, + "allowedTools": [], + "toolsSettings": { + "execute_bash": { + "deniedCommands": [ + ".*base64.*/\\.aws/.*", + ".*cat.*/\\.aws/.*", + ".*cat.*/\\.docker/config\\.json.*", + ".*cat.*/\\.git-credentials.*", + ".*cat.*/\\.gnupg/.*", + ".*cat.*/\\.gpg/.*", + ".*cat.*/\\.kube/config.*", + ".*cat.*/\\.meshclaw/\\.env.*", + ".*cat.*/\\.netrc.*", + ".*cat.*/\\.npmrc.*", + ".*cat.*/\\.pypirc.*", + ".*cat.*/\\.ssh/.*", + ".*cp.*/\\.aws/.*", + ".*cp.*/\\.ssh/.*", + ".*curl.*169\\.254\\.169\\.254.*", + ".*curl.*\\$AWS_ACCESS.*", + ".*curl.*\\$AWS_SECRET.*", + ".*curl.*\\$AWS_SESSION.*", + ".*echo.*\\$AWS_ACCESS.*", + ".*echo.*\\$AWS_SECRET.*", + ".*echo.*\\$AWS_SESSION.*", + ".*env.*grep.*AWS.*", + ".*head.*/\\.aws/.*", + ".*head.*/\\.ssh/.*", + ".*less.*/\\.aws/.*", + ".*less.*/\\.ssh/.*", + ".*more.*/\\.aws/.*", + ".*printenv.*AWS.*", + ".*python.*boto3.*get_credentials.*", + ".*python.*botocore.*credentials.*", + ".*python.*open.*/\\.aws/.*", + ".*python.*open.*/\\.ssh/.*", + ".*strings.*/\\.aws/.*", + ".*tail.*/\\.aws/.*", + ".*tail.*/\\.ssh/.*", + ".*wget.*169\\.254\\.169\\.254.*", + "DROP DATABASE.*", + "DROP TABLE.*", + "TRUNCATE TABLE.*", + "aws autoscaling delete-.*", + "aws cloudformation delete-stack.*", + "aws cloudformation update-termination-protection.*", + "aws dynamodb delete-table.*", + "aws ec2 delete-.*", + "aws ec2 terminate-instances.*", + "aws ecr delete-.*", + "aws ecs delete-.*", + "aws eks delete-cluster.*", + "aws elasticache delete-.*", + "aws elb delete-.*", + "aws elbv2 delete-.*", + "aws glue delete-.*", + "aws iam create-access-key.*", + "aws iam delete-.*", + "aws kinesis delete-.*", + "aws kms schedule-key-deletion.*", + "aws lambda delete-function.*", + "aws logs delete-.*", + "aws opensearch delete-.*", + "aws rds delete-.*", + "aws redshift delete-.*", + "aws route53 delete-.*", + "aws s3 cp .* s3://.*", + "aws s3 mv .* s3://.*", + "aws s3 rb.*", + "aws s3 rm.*", + "aws s3 sync .* s3://.*", + "aws s3api delete-.*", + "aws secretsmanager delete-secret.*", + "aws sns delete-.*", + "aws sqs delete-.*", + "aws stepfunctions delete-.*", + "cdk destroy.*", + "chmod 777.*", + "curl .* \\| bash", + "curl .* \\| sh", + "dd if=.*", + "export AWS_ACCESS.*", + "export AWS_SECRET.*", + "git push.*--force.*", + "git push.*-f .*", + "git reset --hard.*", + "kubectl delete namespace.*", + "mkfs.*", + "nc -e.*", + "ncat -e.*", + "pulumi destroy.*", + "rm -rf /.*", + "rm -rf ~.*", + "terraform destroy.*", + "wget .* \\| bash" + ] + }, + "shell": { + "deniedCommands": [ + ".*base64.*/\\.aws/.*", + ".*cat.*/\\.aws/.*", + ".*cat.*/\\.docker/config\\.json.*", + ".*cat.*/\\.git-credentials.*", + ".*cat.*/\\.gnupg/.*", + ".*cat.*/\\.gpg/.*", + ".*cat.*/\\.kube/config.*", + ".*cat.*/\\.meshclaw/\\.env.*", + ".*cat.*/\\.netrc.*", + ".*cat.*/\\.npmrc.*", + ".*cat.*/\\.pypirc.*", + ".*cat.*/\\.ssh/.*", + ".*cp.*/\\.aws/.*", + ".*cp.*/\\.ssh/.*", + ".*curl.*169\\.254\\.169\\.254.*", + ".*curl.*\\$AWS_ACCESS.*", + ".*curl.*\\$AWS_SECRET.*", + ".*curl.*\\$AWS_SESSION.*", + ".*echo.*\\$AWS_ACCESS.*", + ".*echo.*\\$AWS_SECRET.*", + ".*echo.*\\$AWS_SESSION.*", + ".*env.*grep.*AWS.*", + ".*head.*/\\.aws/.*", + ".*head.*/\\.ssh/.*", + ".*less.*/\\.aws/.*", + ".*less.*/\\.ssh/.*", + ".*more.*/\\.aws/.*", + ".*printenv.*AWS.*", + ".*python.*boto3.*get_credentials.*", + ".*python.*botocore.*credentials.*", + ".*python.*open.*/\\.aws/.*", + ".*python.*open.*/\\.ssh/.*", + ".*strings.*/\\.aws/.*", + ".*tail.*/\\.aws/.*", + ".*tail.*/\\.ssh/.*", + ".*wget.*169\\.254\\.169\\.254.*", + "DROP DATABASE.*", + "DROP TABLE.*", + "TRUNCATE TABLE.*", + "aws autoscaling delete-.*", + "aws cloudformation delete-stack.*", + "aws cloudformation update-termination-protection.*", + "aws dynamodb delete-table.*", + "aws ec2 delete-.*", + "aws ec2 terminate-instances.*", + "aws ecr delete-.*", + "aws ecs delete-.*", + "aws eks delete-cluster.*", + "aws elasticache delete-.*", + "aws elb delete-.*", + "aws elbv2 delete-.*", + "aws glue delete-.*", + "aws iam create-access-key.*", + "aws iam delete-.*", + "aws kinesis delete-.*", + "aws kms schedule-key-deletion.*", + "aws lambda delete-function.*", + "aws logs delete-.*", + "aws opensearch delete-.*", + "aws rds delete-.*", + "aws redshift delete-.*", + "aws route53 delete-.*", + "aws s3 cp .* s3://.*", + "aws s3 mv .* s3://.*", + "aws s3 rb.*", + "aws s3 rm.*", + "aws s3 sync .* s3://.*", + "aws s3api delete-.*", + "aws secretsmanager delete-secret.*", + "aws sns delete-.*", + "aws sqs delete-.*", + "aws stepfunctions delete-.*", + "cdk destroy.*", + "chmod 777.*", + "curl .* \\| bash", + "curl .* \\| sh", + "dd if=.*", + "export AWS_ACCESS.*", + "export AWS_SECRET.*", + "git push.*--force.*", + "git push.*-f .*", + "git reset --hard.*", + "kubectl delete namespace.*", + "mkfs.*", + "nc -e.*", + "ncat -e.*", + "pulumi destroy.*", + "rm -rf /.*", + "rm -rf ~.*", + "terraform destroy.*", + "wget .* \\| bash" + ] + } + } +} diff --git a/agents/manifest.json b/agents/manifest.json index d3a16dbe..63775c76 100644 --- a/agents/manifest.json +++ b/agents/manifest.json @@ -21,6 +21,10 @@ "hash": "ab42104634985d3e", "updated": "2026-09-23" }, + "kiro/studyloop.json": { + "hash": "50ce9fbb28c32855", + "updated": "2026-09-26" + }, "opencode/plugins/studyloop-session-export.js": { "hash": "769ed9efdda111a9", "updated": "2026-09-23" diff --git a/packages/studyloop/src/studyloop/adapters/kiro.py b/packages/studyloop/src/studyloop/adapters/kiro.py index b7e022fa..921fd44f 100644 --- a/packages/studyloop/src/studyloop/adapters/kiro.py +++ b/packages/studyloop/src/studyloop/adapters/kiro.py @@ -31,6 +31,11 @@ os.environ.get("STUDYLOOP_KIRO_AGENTS_DIR", Path.home() / ".kiro" / "agents") ) KIRO_AGENT_NAME = "study-mentor" +#: StudyLoop's own persona-neutral Kiro agent (``agents/kiro/studyloop.json``), +#: installed by ``studyloop install agents`` and named on every Kiro ACP launch, +#: so the learner's *default* Kiro agent never configures StudyLoop's sessions. +#: The persona reaches an ACP session as its first turn, not from this agent. +KIRO_ACP_AGENT_NAME = "studyloop" _KIRO_TEMPLATE = _REPO_ROOT / "agents" / "kiro" / "study-mentor.json" _KIRO_BACKUP_SUFFIX = ".studyloop-backup" diff --git a/packages/studyloop/src/studyloop/cli/_doctor.py b/packages/studyloop/src/studyloop/cli/_doctor.py index a7634d9d..7c61c999 100644 --- a/packages/studyloop/src/studyloop/cli/_doctor.py +++ b/packages/studyloop/src/studyloop/cli/_doctor.py @@ -168,6 +168,7 @@ def _get_registry(): from studyloop.doctor.agents import ( check_agent_definitions, check_agent_smoke_tests, + check_kiro_studyloop_agent, check_mcp_registration, ) from studyloop.doctor.config import ( @@ -237,6 +238,9 @@ def _get_registry(): # signal regardless of whether the binary happens to be on PATH. registry.register("agents")(check_agent_definitions) registry.register("agents")(check_agent_smoke_tests) + # The validity test runs on StudyLoop's own `studyloop` agent -- the one every + # Kiro ACP session names -- never on the learner's default agent. + registry.register("agents")(check_kiro_studyloop_agent) registry.register("agents")(check_mcp_registration) registry.register("harness")(check_harness_export) # check_pypi_versions is deliberately NOT registered. Nothing is published diff --git a/packages/studyloop/src/studyloop/doctor/agents.py b/packages/studyloop/src/studyloop/doctor/agents.py index f722d68c..1631ebf6 100644 --- a/packages/studyloop/src/studyloop/doctor/agents.py +++ b/packages/studyloop/src/studyloop/doctor/agents.py @@ -3,12 +3,15 @@ Checks: 1. Which AI coding tools are installed (binary detection + smoke test) 2. Whether agent definitions are installed and up-to-date (hash vs manifest) + 3. Whether kiro-cli can load StudyLoop's own ``studyloop`` agent, the one + every Kiro ACP session names """ from __future__ import annotations import hashlib import json +import re import shutil import subprocess import urllib.error @@ -172,6 +175,132 @@ def check_agent_smoke_tests() -> list[CheckResult]: return results +#: StudyLoop's own Kiro agent, named on every Kiro ACP launch. +_KIRO_STUDYLOOP_AGENT = "~/.kiro/agents/studyloop.json" +#: ``agent list`` reads every agent file, so it gets longer than the smoke test. +_KIRO_AGENT_CHECK_TIMEOUT = 15 # seconds +#: ``kiro-cli agent list`` colours its scope column even when piped. +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +def _first_line(text: str) -> str: + return next((line.strip() for line in text.splitlines() if line.strip()), "") + + +def check_kiro_studyloop_agent() -> list[CheckResult]: + """Prove kiro-cli can load StudyLoop's own agent -- never the learner's default. + + Web ACP sessions run ``kiro-cli acp --agent studyloop``. Both questions that + decide whether that works are put to kiro-cli itself rather than inferred + from the file: ``agent validate`` (this kiro-cli accepts the config) and + ``agent list`` (it discovers the agent, and no built-in agent of the same + name shadows it -- the way kiro-cli 2.24.0's reserved ``kiro_default`` + silently ignored a learner's own ``kiro_default.json``). Silent when + kiro-cli is not installed; the smoke test already reports that. + """ + from studyloop.adapters.kiro import KIRO_ACP_AGENT_NAME + + binary = shutil.which("kiro-cli") + if not binary: + return [] + + name = "agent_kiro_studyloop_loads" + path = Path(_KIRO_STUDYLOOP_AGENT).expanduser() + if not path.exists(): + return [ + CheckResult( + "agents", + name, + "warn", + ( + f"kiro {KIRO_ACP_AGENT_NAME} agent not installed" + " -- web (ACP) Kiro sessions need it" + ), + "studyloop install agents --tool kiro", + fix_auto=True, + ) + ] + + try: + validated = subprocess.run( + [binary, "agent", "validate", "--path", str(path)], + capture_output=True, + text=True, + timeout=_KIRO_AGENT_CHECK_TIMEOUT, + ) + if validated.returncode != 0: + reason = ( + _first_line(validated.stderr) + or _first_line(validated.stdout) + or f"exit code {validated.returncode}" + ) + return [ + CheckResult( + "agents", + name, + "warn", + f"kiro-cli rejects the {KIRO_ACP_AGENT_NAME} agent: {reason}", + "studyloop install agents --tool kiro, then re-run studyloop doctor", + False, + ) + ] + listed = subprocess.run( + [binary, "agent", "list"], + capture_output=True, + text=True, + timeout=_KIRO_AGENT_CHECK_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return [ + CheckResult( + "agents", + name, + "warn", + f"could not ask kiro-cli about the {KIRO_ACP_AGENT_NAME} agent: {exc}", + "Check kiro-cli installation", + False, + ) + ] + + rows = [_ANSI_ESCAPE.sub("", line).lstrip("* ").split() for line in listed.stdout.splitlines()] + ours = [row for row in rows if row and row[0] == KIRO_ACP_AGENT_NAME] + if any("(Built-in)" in row for row in ours): + return [ + CheckResult( + "agents", + name, + "warn", + ( + f"a built-in kiro-cli agent is also named {KIRO_ACP_AGENT_NAME}, so kiro-cli" + " ignores StudyLoop's agent file" + ), + "Report this to the StudyLoop maintainers: the agent needs a new name", + False, + ) + ] + if not ours: + return [ + CheckResult( + "agents", + name, + "warn", + f"kiro-cli does not list the {KIRO_ACP_AGENT_NAME} agent at {path}", + "studyloop install agents --tool kiro, then re-run studyloop doctor", + False, + ) + ] + return [ + CheckResult( + "agents", + name, + "pass", + f"kiro {KIRO_ACP_AGENT_NAME} agent loads (kiro-cli validates and lists it)", + "", + False, + ) + ] + + def check_agent_definitions() -> list[CheckResult]: """Check that agent definitions are installed and match the manifest.""" tools = _detect_ai_tools() diff --git a/packages/studyloop/src/studyloop/installers.py b/packages/studyloop/src/studyloop/installers.py index 6e9613d7..09566bad 100644 --- a/packages/studyloop/src/studyloop/installers.py +++ b/packages/studyloop/src/studyloop/installers.py @@ -47,6 +47,10 @@ class LinkSpec: _TOOL_LINKS: dict[str, tuple[LinkSpec, ...]] = { "kiro": ( LinkSpec("agents/kiro/study-mentor.json", str(_HOME / ".kiro/agents/study-mentor.json")), + # StudyLoop's own persona-neutral agent, named on every Kiro ACP launch + # (`kiro-cli acp --agent studyloop`) so the learner's default agent never + # configures a StudyLoop session (owner steer, 2026-09-26). + LinkSpec("agents/kiro/studyloop.json", str(_HOME / ".kiro/agents/studyloop.json")), # study-plan-architect.json declares `file://shared/*.md` resources, which # Kiro resolves relative to the agent file's directory; this link is what # makes them exist there (council phase 2, L7 reviewer). diff --git a/packages/studyloop/src/studyloop/web/routes/session/_transport.py b/packages/studyloop/src/studyloop/web/routes/session/_transport.py index 4072fc84..93b26fb2 100644 --- a/packages/studyloop/src/studyloop/web/routes/session/_transport.py +++ b/packages/studyloop/src/studyloop/web/routes/session/_transport.py @@ -97,7 +97,8 @@ def _build_acp_transport(config): # type: ignore[no-untyped-def] Mirrors ``_build_pty_transport`` but builds ACP argv instead of a shell command: - - Kiro: ``["kiro-cli", "acp"]`` + - Kiro: ``["kiro-cli", "acp", "--agent", "studyloop"]`` -- StudyLoop's own + agent (``agents/kiro/studyloop.json``), never the learner's default The ``STUDYLOOP_TEST_ACP_CMD`` env var overrides the argv entirely, matching the shape of ``STUDYLOOP_TEST_AGENT_CMD`` on the PTY side. @@ -139,7 +140,12 @@ def _build_argv(_config) -> list[str]: # type: ignore[no-untyped-def] if test_cmd: return shlex.split(test_cmd) if _config.agent == "kiro": - return ["kiro-cli", "acp"] + # Name StudyLoop's own agent: with no --agent, kiro-cli runs the + # learner's default agent, whose prompt, steering, skills and MCP + # servers are theirs to change and were never StudyLoop's. + from studyloop.adapters.kiro import KIRO_ACP_AGENT_NAME + + return ["kiro-cli", "acp", "--agent", KIRO_ACP_AGENT_NAME] if _config.agent == "grok": # No ``--always-approve``: StudyLoop answers ``session/request_permission`` # itself (``ACPTransport.send_permission_response``), so bypassing the diff --git a/packages/studyloop/tests/test_install_agent_contracts.py b/packages/studyloop/tests/test_install_agent_contracts.py index 8240f988..135e73fb 100644 --- a/packages/studyloop/tests/test_install_agent_contracts.py +++ b/packages/studyloop/tests/test_install_agent_contracts.py @@ -89,6 +89,7 @@ def _definition_sources_by_tool() -> dict[str, set[str]]: "study-mentor.md", "study-plan-architect.json", "study-plan-architect.md", + "studyloop.json", } result: dict[str, set[str]] = {} for tool, sources in _installer_sources_by_tool().items(): diff --git a/packages/studyloop/tests/test_kiro_studyloop_agent.py b/packages/studyloop/tests/test_kiro_studyloop_agent.py index 65c26142..20691551 100644 --- a/packages/studyloop/tests/test_kiro_studyloop_agent.py +++ b/packages/studyloop/tests/test_kiro_studyloop_agent.py @@ -199,7 +199,7 @@ def _run(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess: lambda name: "/opt/bin/kiro-cli" if kiro and name == "kiro-cli" else None, ) monkeypatch.setattr(doctor_agents.subprocess, "run", _run) - return doctor_agents.check_kiro_studyloop_agent(), calls # pyright: ignore[reportAttributeAccessIssue] # RED + return doctor_agents.check_kiro_studyloop_agent(), calls def test_passes_when_kiro_validates_and_lists_it( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -244,7 +244,7 @@ def test_warns_and_auto_fixes_when_not_installed( (result,) = results assert result.status == "warn" assert result.fix_auto is True - assert "studyloop install agents" in result.fix + assert "studyloop install agents" in result.fix_hint assert calls == [] def test_is_silent_without_kiro(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -254,8 +254,6 @@ def test_is_silent_without_kiro(self, monkeypatch: pytest.MonkeyPatch, tmp_path: def test_is_registered_with_doctor(self) -> None: from studyloop.cli._doctor import _get_registry - from studyloop.doctor.agents import ( - check_kiro_studyloop_agent, # pyright: ignore[reportAttributeAccessIssue] # RED - ) + from studyloop.doctor.agents import check_kiro_studyloop_agent assert ("agents", check_kiro_studyloop_agent) in _get_registry()._checkers diff --git a/packages/studyloop/tests/test_web_acp_dogfood_kiro.py b/packages/studyloop/tests/test_web_acp_dogfood_kiro.py index caeec883..1c824828 100644 --- a/packages/studyloop/tests/test_web_acp_dogfood_kiro.py +++ b/packages/studyloop/tests/test_web_acp_dogfood_kiro.py @@ -115,6 +115,9 @@ def _kiro_available() -> tuple[bool, str]: return False, f"kiro-cli whoami errored: {exc}" if result.returncode != 0: return False, f"kiro-cli whoami failed: {result.stderr.strip()[:200]}" + # Every Kiro ACP launch names StudyLoop's own agent (`--agent studyloop`). + if not (Path.home() / ".kiro/agents/studyloop.json").exists(): + return False, "studyloop agent not installed (run: studyloop install agents --tool kiro)" return True, "" @@ -130,8 +133,9 @@ def _kiro_available() -> tuple[bool, str]: def _start_web_server_real_kiro() -> subprocess.Popen: """Spawn ``studyloop web`` with NO STUDYLOOP_TEST_ACP_CMD override. - The route's ``_build_acp_transport`` factory will use ``["kiro-cli", "acp"]`` - — i.e. a real Kiro subprocess. + The route's ``_build_acp_transport`` factory will use + ``["kiro-cli", "acp", "--agent", "studyloop"]`` -- i.e. a real Kiro + subprocess running StudyLoop's own agent. """ env = {**os.environ} env.pop("STUDYLOOP_TEST_ACP_CMD", None) # belt-and-braces diff --git a/scripts/update-agent-manifest.py b/scripts/update-agent-manifest.py index 7aa71e5b..93565f44 100644 --- a/scripts/update-agent-manifest.py +++ b/scripts/update-agent-manifest.py @@ -22,6 +22,7 @@ "kiro": [ "kiro/study-mentor.json", "kiro/study-plan-architect.json", + "kiro/studyloop.json", ], "opencode": [ "opencode/study-mentor.md", From 2ee95b30f728f0a3f58a1f00906783fb8834c065 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Sat, 26 Sep 2026 17:55:15 +0100 Subject: [PATCH 3/5] docs(kiro): the studyloop agent, and kiro-cli's kiro_default.json notice The install guide's Kiro section names the new `studyloop` agent: what it carries, that every Kiro ACP session starts with it, and that doctor checks kiro-cli can load it. Troubleshooting gains the `invalid agent config: kiro_default.json` notice the owner hit when starting "Plan with Architect": kiro-cli 2.24.0 reserves the name, StudyLoop neither reads nor writes the file, and the rename that keeps a customisation. CHANGELOG [Unreleased]. --- CHANGELOG.md | 11 +++++++++++ docs/agent-install.md | 9 +++++++++ docs/troubleshooting.md | 21 +++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d71fdb70..f373bd93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,17 @@ experience may change before `1.0.0`. ### Fixed +- Web (ACP) Kiro sessions run StudyLoop's own `studyloop` agent. They started + `kiro-cli acp` with no `--agent`, so each ran under whatever the learner's + default Kiro agent was: Kiro's built-in (its own system prompt, the learner's + personal steering and skills, every MCP server in their global `mcp.json`) + or the learner's own agent. `studyloop install agents` now installs + `~/.kiro/agents/studyloop.json` (StudyLoop's two MCP servers, the persona + agents' session-export hook and command denylist, no persona, no + pre-approved tools), every Kiro ACP launch names it, and `studyloop doctor` + checks that kiro-cli validates and lists it and that no built-in agent + shadows it. Run `studyloop install agents --tool kiro` (or + `studyloop doctor --fix`) once after upgrading. - The `now` engine counts every "last seen N day(s) ago" from the one clock it reads per plan. The due-progress collector took its day count from a second clock inside `history/progress.py`, so under a frozen test clock the diff --git a/docs/agent-install.md b/docs/agent-install.md index c547cd9b..9c67fd81 100644 --- a/docs/agent-install.md +++ b/docs/agent-install.md @@ -78,6 +78,15 @@ Kiro also receives the `study-plan-architect` agent. Start it directly with: kiro-cli chat --agent study-plan-architect ``` +Kiro also receives `studyloop`, StudyLoop's own agent for web sessions. Every +Study Session or planning session that runs Kiro over ACP starts +`kiro-cli acp --agent studyloop`, so your default Kiro agent — and whatever +prompt, steering, skills or MCP servers it loads — never configures a +StudyLoop session. It carries no persona (the session sends one), reaches only +StudyLoop's `studyloop` and `session-db` MCP servers, and pre-approves no +tools, the same grants ACP sessions had before it existed. You never start it +yourself; `studyloop doctor` checks that kiro-cli validates and lists it. + Kiro also receives the opt-in `studyloop-xtiles-wind-down` skill at `~/.kiro/skills/`, as a symlink to the shared copy described below. It stays silent unless your second-brain provider is `xtiles` and an `xtiles` MCP server is connected. ### Codex diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e55e0b65..a828fc23 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -155,6 +155,27 @@ process is gone `/api/session/state` reports the session ended and the Study view shows the picker. Start a new session; the old one's parking-lot file is left in place. +## Kiro Reports `invalid agent config: kiro_default.json` + +kiro-cli 2.24.0 reserves the agent name `kiro_default`. A file of your own at +`~/.kiro/agents/kiro_default.json` (the usual way to customise Kiro's default +agent before 2.24.0) is ignored, with this notice on every start, including the +Kiro sessions StudyLoop opens. It is kiro-cli's notice about your file: +StudyLoop neither reads nor writes it, and StudyLoop's web sessions run their +own `studyloop` agent whatever your default is. + +To keep your customisation, give it a name kiro-cli has not reserved and make +it the default: + +```bash +cd ~/.kiro/agents +jq '.name = "default-plus"' kiro_default.json > default-plus.json +mv kiro_default.json kiro_default.json.retired +kiro-cli agent set-default default-plus +``` + +If another tool manages that file, check that it will not recreate it first. + ## The Terminal Is Empty After A Page Refresh The panel should reattach on its own. What holds today: From 97bc957f6dc31573a5c0a3dac81b742344b590bc Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Sat, 26 Sep 2026 18:15:46 +0100 Subject: [PATCH 4/5] test(kiro): RED -- the doctor check parses output kiro-cli does not produce The first check was written against output I assumed, and its fixtures assumed the same things, so the tests passed and the check was wrong. Probed against real kiro-cli 2.24.0 on 2026-09-26: 1. `agent list` writes its whole table to stderr; stdout is empty, so the check reported "does not list" on every machine. 2. `agent validate` exits 0 on an invalid file (a wrong-typed field, or a truncated JSON body); the verdict is an `Error:` line on stderr, so the check never flagged a bad file. 3. `acp --agent ` with no such agent does not fail: the session silently runs the built-in `kiro_default`. So the check is the only thing that notices, and its message should say that. Fixtures are now the captured output (paths redacted): ANSI colouring, the `Workspace:`/`Global:` header, a leading `Error:` line for another agent's missing prompt URI, and wrapped description lines. 4 fail for those reasons; 16 pass, including the new description-line-is-not-a-row guard. --- .../tests/test_kiro_studyloop_agent.py | 75 ++++++++++++++++--- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/studyloop/tests/test_kiro_studyloop_agent.py b/packages/studyloop/tests/test_kiro_studyloop_agent.py index 20691551..9722efc6 100644 --- a/packages/studyloop/tests/test_kiro_studyloop_agent.py +++ b/packages/studyloop/tests/test_kiro_studyloop_agent.py @@ -151,13 +151,41 @@ def test_grok_launch_is_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None assert self._argv("grok", monkeypatch) == ["grok", "agent", "stdio"] -# ``kiro-cli agent list`` colours its scope column even when piped. -_LISTING = ( +# Fixtures are kiro-cli 2.24.0's REAL output, captured 2026-09-26 (paths +# redacted), not a guess at it. Three facts the first version of this check +# got wrong, each pinned below: +# 1. `agent list` writes its whole table to STDERR; stdout is empty. +# 2. `agent validate` exits 0 even when the file is invalid -- the verdict is +# an `Error:` line on stderr, not the exit code. +# 3. `acp --agent ` does not fail: the session silently falls back +# to the built-in `kiro_default`. So this check is the only thing that +# notices an uninstalled or unloadable agent. +_RED = "\x1b[38;5;9m" +_GREY = "\x1b[38;5;244m" +_OFF = "\x1b[0m" +_LIST_HEAD = ( + f"{_RED}Error: {_OFF}File URI not found: file:///home/learner/.kiro/agents/prompts/vibe.md\n" + f"{_GREY}Workspace: {_OFF}~/work/.kiro/agents\n" + f"{_GREY}Global: {_OFF}~/.kiro/agents\n" + "\n" "* default-plus Global \n" - " studyloop Global \n" - " kiro_default \x1b[38;5;244m(Built-in)\x1b[0m Default agent\n" + f" kiro_default {_GREY}(Built-in){_OFF} Default agent\n" + " study-mentor Global AuDHD-aware Socratic study mentor\n" + " study sources, shared session history\n" +) +_OURS = ( + " studyloop Global StudyLoop's own agent for web (ACP)\n" + " study and planning sessions\n" +) +_LISTING = _LIST_HEAD + _OURS +_SHADOWED = ( + _LIST_HEAD + f" studyloop {_GREY}(Built-in){_OFF} Default agent\n" +) +# `agent validate` on a wrong-typed file: exit 0, verdict on stderr. +_INVALID = ( + f"{_RED}Error: {_OFF}Json supplied at /home/learner/.kiro/agents/studyloop.json is invalid:" + ' invalid type: string "not-a-list", expected a sequence at line 1 column 43\n' ) -_SHADOWED = " studyloop \x1b[38;5;244m(Built-in)\x1b[0m Default agent\n" class TestDoctorProvesItLoads: @@ -190,7 +218,8 @@ def _run(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess: code, err = validate return subprocess.CompletedProcess(argv, code, "", err) if argv[1:3] == ["agent", "list"]: - return subprocess.CompletedProcess(argv, 0, listing, "") + # The real kiro-cli: table on stderr, nothing on stdout. + return subprocess.CompletedProcess(argv, 0, "", listing) raise AssertionError(f"unexpected command {argv}") monkeypatch.setattr( @@ -208,16 +237,28 @@ def test_passes_when_kiro_validates_and_lists_it( assert [(r.name, r.status) for r in results] == [("agent_kiro_studyloop_loads", "pass")] assert [c[1:3] for c in calls] == [["agent", "validate"], ["agent", "list"]] - def test_warns_when_kiro_rejects_it( + def test_warns_when_kiro_rejects_it_despite_exit_0( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + results, calls = self._run(monkeypatch, tmp_path, validate=(0, _INVALID)) + (result,) = results + assert result.status == "warn" + assert 'invalid type: string "not-a-list"' in result.message + assert "\x1b" not in result.message + assert result.fix_auto is False + assert [c[1:3] for c in calls] == [["agent", "validate"]] + + def test_warns_when_validate_exits_non_zero( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: results, _ = self._run( - monkeypatch, tmp_path, validate=(1, "error: unknown field `hookz`\n") + monkeypatch, + tmp_path, + validate=(1, "error: You are not logged in, please log in with kiro-cli login\n"), ) (result,) = results assert result.status == "warn" - assert "unknown field `hookz`" in result.message - assert result.fix_auto is False + assert "not logged in" in result.message def test_warns_when_a_built_in_shadows_it( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -231,11 +272,21 @@ def test_warns_when_a_built_in_shadows_it( def test_warns_when_kiro_does_not_list_it( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - listing = "* default-plus Global \n" - results, _ = self._run(monkeypatch, tmp_path, listing=listing) + results, _ = self._run(monkeypatch, tmp_path, listing=_LIST_HEAD) (result,) = results assert result.status == "warn" assert "does not list" in result.message + assert "falls back" in result.message + + def test_a_description_line_naming_it_is_not_a_row( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + wrapped = ( + _LIST_HEAD + " studyloop sessions\n" + ) + results, _ = self._run(monkeypatch, tmp_path, listing=wrapped) + (result,) = results + assert result.status == "warn" def test_warns_and_auto_fixes_when_not_installed( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path From 710473a9a4969dfd39eee9661f88ba8d3c713953 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Sat, 26 Sep 2026 18:17:38 +0100 Subject: [PATCH 5/5] fix(doctor): read kiro-cli's verdicts where kiro-cli actually writes them GREEN for 97bc957f. `agent validate`'s verdict is its `Error:` line (the exit code stays 0 on an invalid file, so both now count), and `agent list` is parsed from stderr, where 2.24.0 writes the table (stdout too, in case a release moves it). Rows are matched at column 2 after `" "` or the default marker, so a wrapped description line that mentions `studyloop` is never taken for the agent. The not-listed message now says what that costs: web Kiro sessions silently fall back to kiro-cli's built-in default. Checked against the real kiro-cli 2.24.0, not only the fixtures: a valid, discovered file passes; a wrong-typed file warns with kiro-cli's own reason; a valid file kiro-cli does not discover warns with the fallback. One RED assertion expected "falls back"; the message's grammar is "fall back", so the assertion now pins the full phrase. --- .../studyloop/src/studyloop/doctor/agents.py | 59 ++++++++++++++++--- .../tests/test_kiro_studyloop_agent.py | 2 +- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/studyloop/src/studyloop/doctor/agents.py b/packages/studyloop/src/studyloop/doctor/agents.py index 1631ebf6..92886d00 100644 --- a/packages/studyloop/src/studyloop/doctor/agents.py +++ b/packages/studyloop/src/studyloop/doctor/agents.py @@ -181,12 +181,37 @@ def check_agent_smoke_tests() -> list[CheckResult]: _KIRO_AGENT_CHECK_TIMEOUT = 15 # seconds #: ``kiro-cli agent list`` colours its scope column even when piped. _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") +#: An agent row in ``kiro-cli agent list``: the name starts at column 2, after +#: ``" "`` or the default marker ``"* "``. Wrapped description lines are +#: indented far deeper, so a description mentioning an agent is never a row. +_AGENT_ROW = re.compile(r"^[* ] (\S+)\s+(.*)$") def _first_line(text: str) -> str: return next((line.strip() for line in text.splitlines() if line.strip()), "") +def _error_line(text: str) -> str: + """The first ``Error:`` line kiro-cli printed, colour and prefix removed. + + kiro-cli 2.24.0's ``agent validate`` exits 0 on an invalid file and states + the verdict as ``Error: Json supplied at is invalid: ...``. + """ + for line in _ANSI_ESCAPE.sub("", text).splitlines(): + if line.strip().startswith("Error:"): + return line.strip().removeprefix("Error:").strip() + return "" + + +def _agent_list_rows(text: str) -> list[list[str]]: + """``[name, scope-and-description...]`` for each agent row of ``agent list``.""" + rows: list[list[str]] = [] + for line in _ANSI_ESCAPE.sub("", text).splitlines(): + if match := _AGENT_ROW.match(line): + rows.append([match.group(1), *match.group(2).split()]) + return rows + + def check_kiro_studyloop_agent() -> list[CheckResult]: """Prove kiro-cli can load StudyLoop's own agent -- never the learner's default. @@ -197,6 +222,12 @@ def check_kiro_studyloop_agent() -> list[CheckResult]: name shadows it -- the way kiro-cli 2.24.0's reserved ``kiro_default`` silently ignored a learner's own ``kiro_default.json``). Silent when kiro-cli is not installed; the smoke test already reports that. + + This check is the only signal: an ``acp --agent`` naming an agent kiro-cli + cannot load does not fail, it runs the built-in default. The parsing + follows kiro-cli 2.24.0's real output (probed 2026-09-26): ``validate`` + exits 0 on an invalid file and reports it as an ``Error:`` line on stderr, + and ``list`` writes its table to stderr. """ from studyloop.adapters.kiro import KIRO_ACP_AGENT_NAME @@ -228,18 +259,22 @@ def check_kiro_studyloop_agent() -> list[CheckResult]: text=True, timeout=_KIRO_AGENT_CHECK_TIMEOUT, ) - if validated.returncode != 0: - reason = ( - _first_line(validated.stderr) - or _first_line(validated.stdout) + # kiro-cli 2.24.0 exits 0 on an INVALID file; the verdict is an + # `Error:` line on stderr. So both signals count. + rejection = _error_line(validated.stderr) or _error_line(validated.stdout) + if validated.returncode != 0 and not rejection: + rejection = ( + _first_line(_ANSI_ESCAPE.sub("", validated.stderr)) + or _first_line(_ANSI_ESCAPE.sub("", validated.stdout)) or f"exit code {validated.returncode}" ) + if rejection: return [ CheckResult( "agents", name, "warn", - f"kiro-cli rejects the {KIRO_ACP_AGENT_NAME} agent: {reason}", + f"kiro-cli rejects the {KIRO_ACP_AGENT_NAME} agent: {rejection}", "studyloop install agents --tool kiro, then re-run studyloop doctor", False, ) @@ -262,8 +297,13 @@ def check_kiro_studyloop_agent() -> list[CheckResult]: ) ] - rows = [_ANSI_ESCAPE.sub("", line).lstrip("* ").split() for line in listed.stdout.splitlines()] - ours = [row for row in rows if row and row[0] == KIRO_ACP_AGENT_NAME] + # kiro-cli 2.24.0 writes the table to stderr; read both streams so a future + # release that moves it to stdout still parses. + ours = [ + row + for row in _agent_list_rows(listed.stderr + "\n" + listed.stdout) + if row[0] == KIRO_ACP_AGENT_NAME + ] if any("(Built-in)" in row for row in ours): return [ CheckResult( @@ -284,7 +324,10 @@ def check_kiro_studyloop_agent() -> list[CheckResult]: "agents", name, "warn", - f"kiro-cli does not list the {KIRO_ACP_AGENT_NAME} agent at {path}", + ( + f"kiro-cli does not list the {KIRO_ACP_AGENT_NAME} agent at {path}, so web" + " (ACP) Kiro sessions silently fall back to kiro-cli's built-in default" + ), "studyloop install agents --tool kiro, then re-run studyloop doctor", False, ) diff --git a/packages/studyloop/tests/test_kiro_studyloop_agent.py b/packages/studyloop/tests/test_kiro_studyloop_agent.py index 9722efc6..8468dc33 100644 --- a/packages/studyloop/tests/test_kiro_studyloop_agent.py +++ b/packages/studyloop/tests/test_kiro_studyloop_agent.py @@ -276,7 +276,7 @@ def test_warns_when_kiro_does_not_list_it( (result,) = results assert result.status == "warn" assert "does not list" in result.message - assert "falls back" in result.message + assert "fall back to kiro-cli's built-in default" in result.message def test_a_description_line_naming_it_is_not_a_row( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path