diff --git a/.github/workflows/nightly-install.yml b/.github/workflows/nightly-install.yml index c897c9261..0c8a6ab14 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 f5ee2dcea..aff498a5d 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/CHANGELOG.md b/CHANGELOG.md index d71fdb705..f373bd93c 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/agents/kiro/studyloop.json b/agents/kiro/studyloop.json new file mode 100644 index 000000000..a604ffb30 --- /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 d3a16dbe9..63775c76c 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/docs/agent-install.md b/docs/agent-install.md index c547cd9b3..9c67fd81d 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 e55e0b65e..a828fc23e 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: diff --git a/packages/studyloop/src/studyloop/adapters/kiro.py b/packages/studyloop/src/studyloop/adapters/kiro.py index b7e022fa1..921fd44fe 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 a7634d9d6..7c61c9995 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 f722d68c2..92886d00c 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,175 @@ 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") +#: 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. + + 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. + + 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 + + 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, + ) + # 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: {rejection}", + "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, + ) + ] + + # 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( + "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}, 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, + ) + ] + 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 6e9613d76..09566bad9 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 4072fc84a..93b26fb28 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 8240f9887..135e73fb5 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 new file mode 100644 index 000000000..8468dc334 --- /dev/null +++ b/packages/studyloop/tests/test_kiro_studyloop_agent.py @@ -0,0 +1,310 @@ +"""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"] + + +# 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" + 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' +) + + +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"]: + # The real kiro-cli: table on stderr, nothing on stdout. + 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 + + 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_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: You are not logged in, please log in with kiro-cli login\n"), + ) + (result,) = results + assert result.status == "warn" + assert "not logged in" in result.message + + 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: + results, _ = self._run(monkeypatch, tmp_path, listing=_LIST_HEAD) + (result,) = results + assert result.status == "warn" + assert "does not list" 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 + ) -> 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 + ) -> 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_hint + 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 + + 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 caeec8836..1c8248280 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 7aa71e5b5..93565f44e 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",