studyctl 2.2.0__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. {studyctl-2.2.0 → studyctl-2.4.0}/.gitignore +0 -4
  2. {studyctl-2.2.0 → studyctl-2.4.0}/PKG-INFO +1 -1
  3. {studyctl-2.2.0 → studyctl-2.4.0}/pyproject.toml +2 -1
  4. studyctl-2.4.0/src/studyctl/adapters/__init__.py +29 -0
  5. studyctl-2.4.0/src/studyctl/adapters/_custom.py +159 -0
  6. studyctl-2.4.0/src/studyctl/adapters/_local_llm.py +46 -0
  7. studyctl-2.4.0/src/studyctl/adapters/_protocol.py +53 -0
  8. studyctl-2.4.0/src/studyctl/adapters/_strategies.py +165 -0
  9. studyctl-2.4.0/src/studyctl/adapters/claude.py +36 -0
  10. studyctl-2.4.0/src/studyctl/adapters/gemini.py +46 -0
  11. studyctl-2.4.0/src/studyctl/adapters/kiro.py +113 -0
  12. studyctl-2.4.0/src/studyctl/adapters/lmstudio.py +36 -0
  13. studyctl-2.4.0/src/studyctl/adapters/ollama.py +36 -0
  14. studyctl-2.4.0/src/studyctl/adapters/opencode.py +67 -0
  15. studyctl-2.4.0/src/studyctl/adapters/registry.py +174 -0
  16. studyctl-2.4.0/src/studyctl/agent_launcher.py +404 -0
  17. studyctl-2.4.0/src/studyctl/cli/_study.py +249 -0
  18. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/medication.py +2 -2
  19. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/parking.py +2 -2
  20. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/session/cleanup.py +164 -46
  21. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/session/orchestrator.py +71 -7
  22. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/session/resume.py +18 -4
  23. studyctl-2.2.0/src/studyctl/cli/_study.py → studyctl-2.4.0/src/studyctl/session/start.py +44 -293
  24. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/session_state.py +1 -1
  25. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/settings.py +80 -53
  26. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/shared.py +4 -1
  27. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/tui/sidebar.py +96 -33
  28. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/app.py +5 -2
  29. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/routes/artefacts.py +3 -1
  30. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/routes/session.py +58 -5
  31. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/routes/terminal_proxy.py +5 -1
  32. studyctl-2.4.0/src/studyctl/web/static/components.js +624 -0
  33. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/index.html +213 -151
  34. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/style.css +273 -28
  35. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/sw.js +15 -1
  36. {studyctl-2.2.0 → studyctl-2.4.0}/tests/harness/study.py +7 -3
  37. {studyctl-2.2.0 → studyctl-2.4.0}/tests/harness/terminal.py +6 -2
  38. {studyctl-2.2.0 → studyctl-2.4.0}/tests/harness/tmux.py +10 -3
  39. studyctl-2.4.0/tests/test_adapter_builtins.py +584 -0
  40. studyctl-2.4.0/tests/test_adapter_custom.py +138 -0
  41. studyctl-2.4.0/tests/test_adapter_protocol.py +79 -0
  42. studyctl-2.4.0/tests/test_adapter_registry.py +397 -0
  43. studyctl-2.4.0/tests/test_adapter_strategies.py +99 -0
  44. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_break_logic.py +8 -0
  45. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_briefing_logic.py +2 -2
  46. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_e2e_session_demo.py +1 -0
  47. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_lan_auth.py +1 -1
  48. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_parking.py +6 -1
  49. studyctl-2.4.0/tests/test_session_cleanup.py +516 -0
  50. studyctl-2.4.0/tests/test_session_start.py +267 -0
  51. studyctl-2.4.0/tests/test_settings_custom.py +305 -0
  52. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_terminal_proxy.py +7 -3
  53. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_topic_resolver.py +8 -0
  54. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_web_app.py +1 -1
  55. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_web_artefacts.py +1 -1
  56. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_web_session.py +5 -3
  57. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_web_terminal.py +120 -67
  58. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_web_vendor.py +7 -2
  59. studyctl-2.2.0/src/studyctl/agent_launcher.py +0 -585
  60. studyctl-2.2.0/src/studyctl/web/server.py +0 -259
  61. studyctl-2.2.0/src/studyctl/web/static/components.js +0 -686
  62. studyctl-2.2.0/tests/test_content_cli.py +0 -489
  63. studyctl-2.2.0/tests/test_lazy_imports.py +0 -40
  64. studyctl-2.2.0/tests/test_migrate_from_empty_db.py +0 -107
  65. studyctl-2.2.0/tests/test_services_content.py +0 -144
  66. studyctl-2.2.0/tests/test_services_review.py +0 -265
  67. studyctl-2.2.0/tests/test_web_cli.py +0 -143
  68. studyctl-2.2.0/tests/test_web_sidebar.py +0 -493
  69. {studyctl-2.2.0 → studyctl-2.4.0}/README.md +0 -0
  70. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/__init__.py +0 -0
  71. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/__init__.py +0 -0
  72. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/__main__.py +0 -0
  73. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_backup.py +0 -0
  74. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_clean.py +0 -0
  75. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_config.py +0 -0
  76. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_content.py +0 -0
  77. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_doctor.py +0 -0
  78. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_lazy.py +0 -0
  79. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_review.py +0 -0
  80. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_session.py +0 -0
  81. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_setup.py +0 -0
  82. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_shared.py +0 -0
  83. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_sync.py +0 -0
  84. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_topics.py +0 -0
  85. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_upgrade.py +0 -0
  86. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/cli/_web.py +0 -0
  87. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/content/__init__.py +0 -0
  88. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/content/markdown_converter.py +0 -0
  89. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/content/models.py +0 -0
  90. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/content/notebooklm_client.py +0 -0
  91. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/content/splitter.py +0 -0
  92. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/content/storage.py +0 -0
  93. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/content/syllabus.py +0 -0
  94. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/data/tmux-studyctl.conf +0 -0
  95. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/db.py +0 -0
  96. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/__init__.py +0 -0
  97. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/agents.py +0 -0
  98. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/config.py +0 -0
  99. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/core.py +0 -0
  100. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/database.py +0 -0
  101. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/deps.py +0 -0
  102. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/models.py +0 -0
  103. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/doctor/updates.py +0 -0
  104. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/__init__.py +0 -0
  105. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/_connection.py +0 -0
  106. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/bridges.py +0 -0
  107. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/concepts.py +0 -0
  108. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/progress.py +0 -0
  109. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/search.py +0 -0
  110. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/sessions.py +0 -0
  111. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/streaks.py +0 -0
  112. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/history/teachback.py +0 -0
  113. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/logic/__init__.py +0 -0
  114. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/logic/backlog_logic.py +0 -0
  115. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/logic/break_logic.py +0 -0
  116. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/logic/briefing_logic.py +0 -0
  117. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/logic/clean_logic.py +0 -0
  118. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/logic/streaks_logic.py +0 -0
  119. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/logic/topic_resolver.py +0 -0
  120. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/maintenance.py +0 -0
  121. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/mcp/__init__.py +0 -0
  122. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/mcp/server.py +0 -0
  123. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/mcp/tools.py +0 -0
  124. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/output.py +0 -0
  125. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/pdf.py +0 -0
  126. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/review_db.py +0 -0
  127. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/review_loader.py +0 -0
  128. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/services/__init__.py +0 -0
  129. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/services/backlog.py +0 -0
  130. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/services/content.py +0 -0
  131. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/services/flashcard_writer.py +0 -0
  132. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/services/review.py +0 -0
  133. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/session/__init__.py +0 -0
  134. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/state.py +0 -0
  135. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/sync.py +0 -0
  136. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/tmux.py +0 -0
  137. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/topics.py +0 -0
  138. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/tui/__init__.py +0 -0
  139. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/tui/__main__.py +0 -0
  140. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/__init__.py +0 -0
  141. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/auth.py +0 -0
  142. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/routes/__init__.py +0 -0
  143. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/routes/cards.py +0 -0
  144. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/routes/courses.py +0 -0
  145. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/routes/history.py +0 -0
  146. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/icon-192.svg +0 -0
  147. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/icon-512.svg +0 -0
  148. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/manifest.json +0 -0
  149. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/inter-latin-ext.woff2 +0 -0
  150. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/inter-latin.woff2 +0 -0
  151. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff +0 -0
  152. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff2 +0 -0
  153. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/inter.css +0 -0
  154. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/opendyslexic-400.css +0 -0
  155. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js +0 -0
  156. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/js/htmx-2.0.4.min.js +0 -0
  157. {studyctl-2.2.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/js/htmx-ext-sse-2.2.2.js +0 -0
  158. {studyctl-2.2.0 → studyctl-2.4.0}/tests/__init__.py +0 -0
  159. {studyctl-2.2.0 → studyctl-2.4.0}/tests/_helpers.py +0 -0
  160. {studyctl-2.2.0 → studyctl-2.4.0}/tests/harness/__init__.py +0 -0
  161. {studyctl-2.2.0 → studyctl-2.4.0}/tests/harness/agents.py +0 -0
  162. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_agent_launcher.py +0 -0
  163. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_backlog_logic.py +0 -0
  164. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_backup.py +0 -0
  165. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_clean.py +0 -0
  166. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_cli.py +0 -0
  167. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_cli_doctor.py +0 -0
  168. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_cli_session.py +0 -0
  169. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_cli_upgrade.py +0 -0
  170. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_content_notebooklm.py +0 -0
  171. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_content_splitter.py +0 -0
  172. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_content_storage.py +0 -0
  173. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_content_syllabus.py +0 -0
  174. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_agents.py +0 -0
  175. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_config.py +0 -0
  176. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_core.py +0 -0
  177. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_database.py +0 -0
  178. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_deps.py +0 -0
  179. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_integration.py +0 -0
  180. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_models.py +0 -0
  181. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_doctor_updates.py +0 -0
  182. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_flashcard_writer.py +0 -0
  183. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_harness_matrix.py +0 -0
  184. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_history.py +0 -0
  185. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_install_mentor_prompt.py +0 -0
  186. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_mcp_tools.py +0 -0
  187. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_orchestrator.py +0 -0
  188. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_review_db.py +0 -0
  189. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_review_loader.py +0 -0
  190. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_session_db_integration.py +0 -0
  191. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_session_state.py +0 -0
  192. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_setup_wizard.py +0 -0
  193. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_shared.py +0 -0
  194. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_sidebar_pilot.py +0 -0
  195. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_streaks_logic.py +0 -0
  196. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_study.py +0 -0
  197. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_study_integration.py +0 -0
  198. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_study_lifecycle.py +0 -0
  199. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_tmux.py +0 -0
  200. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_topics_cli.py +0 -0
  201. {studyctl-2.2.0 → studyctl-2.4.0}/tests/test_uat_terminal.py +0 -0
@@ -496,10 +496,6 @@ uv-*.lock
496
496
 
497
497
  # Iterate runner artefacts (autoresearch pattern — kept untracked)
498
498
  results.tsv
499
- eval-results.tsv
500
499
  .pytest-junit.xml
501
500
  docs/reports/
502
501
  docs/brainstorms/
503
-
504
- # Dev-only tooling (autoresearch eval harness — not shipped to users)
505
- dev/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: studyctl
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline
5
5
  Project-URL: Homepage, https://github.com/NetDevAutomate/socratic-study-mentor
6
6
  Project-URL: Repository, https://github.com/NetDevAutomate/socratic-study-mentor
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "studyctl"
3
- version = "2.2.0"
3
+ version = "2.4.0"
4
4
  description = "AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline"
5
5
  requires-python = ">=3.12"
6
6
  readme = "README.md"
@@ -58,6 +58,7 @@ markers = [
58
58
  [tool.pyright]
59
59
  pythonVersion = "3.12"
60
60
  typeCheckingMode = "basic"
61
+ extraPaths = ["../agent-session-tools/src"] # workspace sibling for cross-package imports
61
62
  exclude = ["src/studyctl/tui", "src/studyctl/content", "src/studyctl/cli/_content.py", "src/studyctl/cli/_web.py", "src/studyctl/web", "src/studyctl/mcp", "src/studyctl/eval/llm_client.py"] # optional deps not in CI
62
63
 
63
64
  [dependency-groups]
@@ -0,0 +1,29 @@
1
+ """Agent adapter package — modular agent detection and launch.
2
+
3
+ Usage:
4
+ from studyctl.adapters import detect_agents, get_adapter
5
+
6
+ agents = detect_agents()
7
+ adapter = get_adapter('claude')
8
+ persona = adapter.setup(content, session_dir)
9
+ cmd = adapter.launch_cmd(persona, resume=False)
10
+ """
11
+
12
+ from studyctl.adapters._protocol import AdapterProtocol, AgentAdapter
13
+ from studyctl.adapters.registry import (
14
+ detect_agents,
15
+ get_adapter,
16
+ get_all_adapters,
17
+ get_default_agent,
18
+ reset_registry,
19
+ )
20
+
21
+ __all__ = [
22
+ "AdapterProtocol",
23
+ "AgentAdapter",
24
+ "detect_agents",
25
+ "get_adapter",
26
+ "get_all_adapters",
27
+ "get_default_agent",
28
+ "reset_registry",
29
+ ]
@@ -0,0 +1,159 @@
1
+ """Config-driven factory for custom agent adapters.
2
+
3
+ Reads the ``agents.custom`` section from config.yaml and builds
4
+ ``AgentAdapter`` instances at runtime. This allows users to add any
5
+ CLI-based AI agent without modifying source code.
6
+
7
+ Example config::
8
+
9
+ agents:
10
+ custom:
11
+ aider:
12
+ binary: aider
13
+ strategy: cli-flag
14
+ launch: "{binary} --read {persona}"
15
+ resume: "{binary} --read {persona} --resume"
16
+ env:
17
+ OPENAI_API_KEY: sk-...
18
+ my-agent:
19
+ binary: my-agent
20
+ strategy: cwd-file
21
+ filename: .context.md
22
+ launch: "{binary}"
23
+ teardown: "pkill -f my-agent"
24
+ mcp:
25
+ format: generic
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import functools
31
+ import logging
32
+ import shutil
33
+ import subprocess
34
+ from typing import TYPE_CHECKING
35
+
36
+ from studyctl.adapters._protocol import AgentAdapter
37
+ from studyctl.adapters._strategies import cli_flag_setup, cwd_file_setup, write_mcp_config
38
+
39
+ if TYPE_CHECKING:
40
+ from pathlib import Path
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+ # Supported strategy names → setup callables
45
+ _STRATEGIES = {
46
+ "cli-flag": cli_flag_setup,
47
+ "cwd-file": cwd_file_setup,
48
+ }
49
+
50
+
51
+ def build_custom_adapter(name: str, config: dict) -> AgentAdapter:
52
+ """Build an ``AgentAdapter`` from a config dict.
53
+
54
+ Args:
55
+ name: The adapter name (key from ``agents.custom``).
56
+ config: The raw config dict for this adapter.
57
+
58
+ Returns:
59
+ A fully-constructed ``AgentAdapter`` instance.
60
+
61
+ Raises:
62
+ ValueError: If ``strategy`` is not ``"cli-flag"`` or ``"cwd-file"``.
63
+ """
64
+ binary: str = config["binary"]
65
+ strategy_name: str = config["strategy"]
66
+ launch_template: str = config["launch"]
67
+ resume_template: str = config.get("resume", launch_template)
68
+ env_vars: dict[str, str] = config.get("env", {}) or {}
69
+ teardown_cmd: str | None = config.get("teardown")
70
+ mcp_config = config.get("mcp")
71
+
72
+ # --- Strategy -----------------------------------------------------------
73
+ if strategy_name not in _STRATEGIES:
74
+ raise ValueError(
75
+ f"Unknown strategy {strategy_name!r} for adapter {name!r}. "
76
+ f"Must be one of: {sorted(_STRATEGIES)}"
77
+ )
78
+
79
+ if strategy_name == "cwd-file":
80
+ filename = config.get("filename", "PERSONA.md")
81
+ setup_fn = functools.partial(cwd_file_setup, filename=filename)
82
+ else:
83
+ setup_fn = _STRATEGIES[strategy_name]
84
+
85
+ # --- Launch command builder ---------------------------------------------
86
+ def _make_launch_cmd(persona_path: Path, resume: bool) -> str:
87
+ template = resume_template if resume else launch_template
88
+ resolved = shutil.which(binary) or binary
89
+ cmd = template.format(
90
+ binary=resolved,
91
+ persona=str(persona_path),
92
+ session_dir=str(persona_path.parent),
93
+ )
94
+ if env_vars:
95
+ prefix = " ".join(f"{k}={v}" for k, v in env_vars.items())
96
+ cmd = f"export {prefix}; {cmd}"
97
+ return cmd
98
+
99
+ # --- Teardown -----------------------------------------------------------
100
+ teardown_fn = None
101
+ if teardown_cmd:
102
+ _cmd = teardown_cmd # capture in closure
103
+
104
+ def _teardown(_session_dir: Path) -> None:
105
+ try:
106
+ # shell=True is intentional: teardown_cmd is a user-supplied shell string
107
+ # (e.g. "pkill -f my-agent") that requires shell interpretation.
108
+ subprocess.run(_cmd, shell=True, timeout=10, check=False) # nosec B602
109
+ except Exception as exc:
110
+ log.warning("Teardown command %r failed: %s", _cmd, exc)
111
+
112
+ teardown_fn = _teardown
113
+
114
+ # --- MCP setup ----------------------------------------------------------
115
+ mcp_fn = None
116
+ if mcp_config is not None:
117
+ if mcp_config is True:
118
+ _fmt = "generic"
119
+ _path = None
120
+ else:
121
+ _fmt = mcp_config.get("format", "generic")
122
+ _path = mcp_config.get("path")
123
+
124
+ def _mcp_setup(session_dir: Path) -> None:
125
+ write_mcp_config(session_dir, fmt=_fmt, path=_path)
126
+
127
+ mcp_fn = _mcp_setup
128
+
129
+ return AgentAdapter(
130
+ name=name,
131
+ binary=binary,
132
+ setup=setup_fn,
133
+ launch_cmd=_make_launch_cmd,
134
+ teardown=teardown_fn,
135
+ mcp_setup=mcp_fn,
136
+ )
137
+
138
+
139
+ def load_custom_adapters() -> dict[str, AgentAdapter]:
140
+ """Load custom adapters from the ``agents.custom`` config section.
141
+
142
+ Returns:
143
+ Dict mapping adapter name → ``AgentAdapter``. Empty if no custom
144
+ adapters are configured or the config cannot be loaded.
145
+ """
146
+ from studyctl.settings import load_settings
147
+
148
+ customs = getattr(load_settings().agents, "custom", None)
149
+ if not customs:
150
+ return {}
151
+
152
+ result: dict[str, AgentAdapter] = {}
153
+ for adapter_name, adapter_config in customs.items():
154
+ try:
155
+ result[adapter_name] = build_custom_adapter(adapter_name, adapter_config)
156
+ except Exception as exc:
157
+ log.warning("Failed to build custom adapter %r: %s", adapter_name, exc)
158
+
159
+ return result
@@ -0,0 +1,46 @@
1
+ """Shared helpers for local LLM adapters (Ollama and LM Studio).
2
+
3
+ Both adapters use Claude Code as the frontend but point it at a local
4
+ LLM backend via environment variables. These helpers are extracted here
5
+ to avoid duplication between ollama.py and lmstudio.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def _get_local_llm_config(provider: str) -> tuple[str, str]:
12
+ """Return (base_url, model) for a local LLM provider from config.
13
+
14
+ Falls back to sensible defaults if config isn't available.
15
+ """
16
+ defaults = {
17
+ "ollama": ("http://localhost:4000", "qwen3-coder"), # LiteLLM proxy
18
+ "lmstudio": ("http://localhost:1234", "qwen3-coder"),
19
+ }
20
+ try:
21
+ from studyctl.settings import load_settings
22
+
23
+ cfg = getattr(load_settings().agents, provider, None)
24
+ if cfg and cfg.model:
25
+ return cfg.base_url or defaults[provider][0], cfg.model
26
+ except Exception:
27
+ pass
28
+ return defaults[provider]
29
+
30
+
31
+ def _local_llm_env_prefix(base_url: str, auth_token: str, model: str) -> str:
32
+ """Build shell env var exports for a local LLM provider.
33
+
34
+ Tier-pins all Claude Code model tiers to the same model, since
35
+ local LLMs only serve one model at a time. Without this, Claude
36
+ tries to use different models for sub-agents and fast tasks.
37
+ """
38
+ return (
39
+ f"export ANTHROPIC_BASE_URL={base_url} "
40
+ f"ANTHROPIC_AUTH_TOKEN={auth_token} "
41
+ f"ANTHROPIC_MODEL={model} "
42
+ f"ANTHROPIC_SMALL_FAST_MODEL={model} "
43
+ f"ANTHROPIC_DEFAULT_HAIKU_MODEL={model} "
44
+ f"ANTHROPIC_DEFAULT_SONNET_MODEL={model} "
45
+ f"ANTHROPIC_DEFAULT_OPUS_MODEL={model}; "
46
+ )
@@ -0,0 +1,53 @@
1
+ """Agent adapter Protocol and dataclass.
2
+
3
+ The AdapterProtocol defines the contract that every agent adapter
4
+ must satisfy. AgentAdapter is the concrete frozen dataclass that
5
+ implements it using callables for each behaviour.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+
17
+
18
+ @runtime_checkable
19
+ class AdapterProtocol(Protocol):
20
+ """Contract for agent adapters.
21
+
22
+ Any object with these attributes and methods can act as an adapter.
23
+ The AgentAdapter dataclass satisfies this Protocol, but custom
24
+ implementations can use any class as long as it provides these fields.
25
+ """
26
+
27
+ name: str
28
+ binary: str
29
+
30
+ def setup(self, canonical_content: str, session_dir: Path) -> Path: ...
31
+ def launch_cmd(self, persona_path: Path, resume: bool) -> str: ...
32
+ def teardown(self, session_dir: Path) -> None: ...
33
+ def mcp_setup(self, session_dir: Path) -> None: ...
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AgentAdapter:
38
+ """Configuration and behaviour for one AI coding agent.
39
+
40
+ Each field is either static data or a callable that handles
41
+ the agent-specific mechanism for persona injection and launch.
42
+ """
43
+
44
+ name: str
45
+ binary: str
46
+ setup: Callable[[str, Path], Path]
47
+ """(canonical_content, session_dir) -> persona_path"""
48
+ launch_cmd: Callable[[Path, bool], str]
49
+ """(persona_path, resume) -> shell command string"""
50
+ teardown: Callable[[Path], None] | None = None
51
+ """Optional cleanup for agents that write global state."""
52
+ mcp_setup: Callable[[Path], None] | None = None
53
+ """Optional MCP config writer for the session directory."""
@@ -0,0 +1,165 @@
1
+ """Reusable persona injection strategies and MCP config writers.
2
+
3
+ These are the building blocks for constructing AgentAdapter instances.
4
+ Each strategy handles one specific mechanism (temp file, CWD file, MCP
5
+ config) so adapter definitions remain declarative and DRY.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import stat
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+
18
+ def cli_flag_setup(canonical_content: str, _session_dir: Path) -> Path:
19
+ """Write persona to a secure temp file for agents that accept --flag /path.
20
+
21
+ Creates a file with 0o600 permissions (owner read/write only) so the
22
+ persona content is not world-readable on multi-user systems.
23
+
24
+ Args:
25
+ canonical_content: The rendered persona markdown content.
26
+ _session_dir: Unused; accepted to satisfy the setup callable signature.
27
+
28
+ Returns:
29
+ Path to the temporary file. Caller is responsible for cleanup.
30
+ """
31
+ fd, tmp = tempfile.mkstemp(suffix=".md", prefix="studyctl-persona-")
32
+ try:
33
+ os.write(fd, canonical_content.encode())
34
+ finally:
35
+ os.close(fd)
36
+
37
+ # Set secure permissions: owner read+write only
38
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
39
+
40
+ return Path(tmp)
41
+
42
+
43
+ def cwd_file_setup(
44
+ canonical_content: str,
45
+ session_dir: Path,
46
+ *,
47
+ filename: str = "PERSONA.md",
48
+ ) -> Path:
49
+ """Write persona as a named file inside the session directory.
50
+
51
+ Used by agents (e.g. Kiro) that pick up context files from the CWD
52
+ rather than accepting an explicit flag.
53
+
54
+ Args:
55
+ canonical_content: The rendered persona markdown content.
56
+ session_dir: Directory where the persona file will be written.
57
+ filename: Name for the persona file. Defaults to ``PERSONA.md``.
58
+
59
+ Returns:
60
+ Path to the written file.
61
+ """
62
+ dest = session_dir / filename
63
+ dest.write_text(canonical_content, encoding="utf-8")
64
+ return dest
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # MCP config writer
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _mcp_command() -> list[str]:
73
+ """Return the command list for launching studyctl-mcp.
74
+
75
+ Checks PATH first so installed tool users get the fast path. Falls
76
+ back to ``uv run`` against the workspace package so development
77
+ checkouts work without a separate install step.
78
+
79
+ Returns:
80
+ A list suitable for use as ``command + args`` in an MCP config.
81
+ """
82
+ if shutil.which("studyctl-mcp"):
83
+ return ["studyctl-mcp"]
84
+
85
+ # Repo root is six levels up from this file:
86
+ # packages/studyctl/src/studyctl/adapters/_strategies.py
87
+ # ^adapters
88
+ # ^studyctl
89
+ # ^src
90
+ # ^studyctl (package)
91
+ # ^packages
92
+ # ^repo_root
93
+ repo_root = Path(__file__).parent.parent.parent.parent.parent.parent
94
+ return [
95
+ "uv",
96
+ "run",
97
+ "--project",
98
+ str(repo_root / "packages" / "studyctl"),
99
+ "studyctl-mcp",
100
+ ]
101
+
102
+
103
+ def write_mcp_config(
104
+ session_dir: Path,
105
+ *,
106
+ fmt: str = "generic",
107
+ path: str | None = None,
108
+ ) -> None:
109
+ """Write the MCP server configuration JSON for the given agent format.
110
+
111
+ Supported formats:
112
+
113
+ * ``"generic"`` — Claude Code / generic MCP schema at ``.mcp.json``
114
+ * ``"gemini"`` — Gemini CLI schema at ``.gemini/settings.json``
115
+ * ``"opencode"`` — OpenCode schema at ``.opencode/opencode.json``
116
+
117
+ Args:
118
+ session_dir: Root of the session workspace; config paths are
119
+ resolved relative to this directory.
120
+ fmt: Target config format. Must be one of the supported strings.
121
+ path: Override the default config file path (relative to
122
+ ``session_dir``). If omitted the format-specific default is used.
123
+
124
+ Raises:
125
+ ValueError: If ``fmt`` is not a recognised format string.
126
+ """
127
+ cmd = _mcp_command()
128
+
129
+ if fmt == "generic":
130
+ default_path = ".mcp.json"
131
+ config = {
132
+ "mcpServers": {
133
+ "studyctl-mcp": {
134
+ "command": cmd[0],
135
+ "args": cmd[1:],
136
+ }
137
+ }
138
+ }
139
+ elif fmt == "gemini":
140
+ default_path = ".gemini/settings.json"
141
+ config = {
142
+ "mcpServers": {
143
+ "studyctl-mcp": {
144
+ "command": cmd[0],
145
+ "args": cmd[1:],
146
+ }
147
+ }
148
+ }
149
+ elif fmt == "opencode":
150
+ default_path = ".opencode/opencode.json"
151
+ config = {
152
+ "mcp": {
153
+ "studyctl-mcp": {
154
+ "command": cmd,
155
+ "enabled": True,
156
+ "type": "local",
157
+ }
158
+ }
159
+ }
160
+ else:
161
+ raise ValueError(f"Unknown MCP config format: {fmt!r}")
162
+
163
+ target = session_dir / (path or default_path)
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ target.write_text(json.dumps(config, indent=2), encoding="utf-8")
@@ -0,0 +1,36 @@
1
+ """Claude adapter — persona via --append-system-prompt-file flag.
2
+
3
+ Claude Code accepts a temp file path via this flag. The file is written
4
+ with 0600 permissions so persona content is not world-readable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ from typing import TYPE_CHECKING
11
+
12
+ from studyctl.adapters._protocol import AgentAdapter
13
+ from studyctl.adapters._strategies import cli_flag_setup
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+
19
+ def _claude_launch(persona_path: Path, resume: bool) -> str:
20
+ """Build Claude launch command with absolute binary path.
21
+
22
+ Resolves to absolute path because tmux panes run non-interactive
23
+ shells which don't source .zshrc (~/.local/bin not in PATH).
24
+ """
25
+ binary = shutil.which("claude") or "claude"
26
+ if resume:
27
+ return f"{binary} -r --append-system-prompt-file {persona_path}"
28
+ return f"{binary} --append-system-prompt-file {persona_path}"
29
+
30
+
31
+ ADAPTER = AgentAdapter(
32
+ name="claude",
33
+ binary="claude",
34
+ setup=cli_flag_setup,
35
+ launch_cmd=_claude_launch,
36
+ )
@@ -0,0 +1,46 @@
1
+ """Gemini adapter — persona via GEMINI.md in session CWD.
2
+
3
+ Gemini CLI auto-loads GEMINI.md from the current working directory
4
+ (3-tier hierarchy: global, workspace, JIT). MCP config goes in
5
+ .gemini/settings.json in the session directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from typing import TYPE_CHECKING
12
+
13
+ from studyctl.adapters._protocol import AgentAdapter
14
+ from studyctl.adapters._strategies import write_mcp_config
15
+
16
+ if TYPE_CHECKING:
17
+ from pathlib import Path
18
+
19
+
20
+ def _gemini_setup(canonical_content: str, session_dir: Path) -> Path:
21
+ """Write GEMINI.md to session dir (auto-loaded by Gemini CLI from cwd)."""
22
+ persona_path = session_dir / "GEMINI.md"
23
+ persona_path.write_text(canonical_content)
24
+ return persona_path
25
+
26
+
27
+ def _gemini_launch(_persona_path: Path, resume: bool) -> str:
28
+ """Build Gemini launch command. Gemini picks up GEMINI.md from cwd."""
29
+ binary = shutil.which("gemini") or "gemini"
30
+ if resume:
31
+ return f"{binary} -r"
32
+ return binary
33
+
34
+
35
+ def _gemini_mcp(session_dir: Path) -> None:
36
+ """Write .gemini/settings.json with studyctl-mcp server config."""
37
+ write_mcp_config(session_dir, fmt="gemini")
38
+
39
+
40
+ ADAPTER = AgentAdapter(
41
+ name="gemini",
42
+ binary="gemini",
43
+ setup=_gemini_setup,
44
+ launch_cmd=_gemini_launch,
45
+ mcp_setup=_gemini_mcp,
46
+ )
@@ -0,0 +1,113 @@
1
+ """Kiro adapter — persona via ~/.kiro/agents/study-mentor.json.
2
+
3
+ Kiro loads agents natively from ~/.kiro/agents/. The setup function
4
+ writes canonical content to a temp persona file, then updates the
5
+ agent JSON's "prompt" field to reference it via file:// URI.
6
+ Teardown restores the original JSON from a backup.
7
+
8
+ Crash recovery: stale backups are restored on next setup to prevent
9
+ Kiro from permanently using a studyctl-managed prompt.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import shutil
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ from studyctl.adapters._protocol import AgentAdapter
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Repo root: adapters/kiro.py is at
26
+ # packages/studyctl/src/studyctl/adapters/kiro.py
27
+ # So repo root is six levels up.
28
+ _REPO_ROOT = Path(__file__).parent.parent.parent.parent.parent.parent
29
+
30
+ KIRO_AGENTS_DIR = Path(os.environ.get("STUDYCTL_KIRO_AGENTS_DIR", Path.home() / ".kiro" / "agents"))
31
+ KIRO_AGENT_NAME = "study-mentor"
32
+ _KIRO_TEMPLATE = _REPO_ROOT / "agents" / "kiro" / "study-mentor.json"
33
+ _KIRO_BACKUP_SUFFIX = ".studyctl-backup"
34
+
35
+
36
+ def _kiro_setup(canonical_content: str, _session_dir: Path) -> Path:
37
+ """Write persona temp file and update Kiro agent JSON atomically."""
38
+ # 1. Write canonical content to a temp persona file
39
+ fd, persona_path = tempfile.mkstemp(
40
+ prefix="studyctl-kiro-persona-",
41
+ suffix=".md",
42
+ dir=tempfile.gettempdir(),
43
+ )
44
+ os.fchmod(fd, 0o600)
45
+ with os.fdopen(fd, "w") as f:
46
+ f.write(canonical_content)
47
+
48
+ # 2. Load the base agent template
49
+ if _KIRO_TEMPLATE.exists():
50
+ agent_def = json.loads(_KIRO_TEMPLATE.read_text())
51
+ else:
52
+ agent_def = {
53
+ "name": KIRO_AGENT_NAME,
54
+ "description": "Socratic study mentor",
55
+ }
56
+
57
+ # 3. Update prompt to reference the temp persona file
58
+ agent_def["prompt"] = f"file://{persona_path}"
59
+
60
+ # 4. Ensure target directory exists
61
+ KIRO_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
62
+ target = KIRO_AGENTS_DIR / f"{KIRO_AGENT_NAME}.json"
63
+ backup = target.with_suffix(target.suffix + _KIRO_BACKUP_SUFFIX)
64
+
65
+ # 4a. Recover from crash: if a backup exists, the previous session's
66
+ # teardown never ran. Restore the user's original config first.
67
+ if backup.exists():
68
+ logger.warning(
69
+ "Stale Kiro backup detected (previous session crashed?) — restoring %s",
70
+ backup,
71
+ )
72
+ os.replace(backup, target)
73
+
74
+ # 5. Backup existing agent JSON if present
75
+ if target.exists():
76
+ shutil.copy2(target, backup)
77
+
78
+ # 6. Atomic write: temp file in same dir → os.replace()
79
+ fd2, tmp_json = tempfile.mkstemp(
80
+ prefix=f"{KIRO_AGENT_NAME}-",
81
+ suffix=".json",
82
+ dir=str(KIRO_AGENTS_DIR),
83
+ )
84
+ with os.fdopen(fd2, "w") as f:
85
+ json.dump(agent_def, f, indent=2)
86
+ os.replace(tmp_json, target)
87
+
88
+ return Path(persona_path)
89
+
90
+
91
+ def _kiro_launch(_persona_path: Path, resume: bool) -> str:
92
+ """Build Kiro launch command."""
93
+ binary = shutil.which("kiro-cli") or shutil.which("kiro") or "kiro-cli"
94
+ if resume:
95
+ return f"{binary} chat --agent {KIRO_AGENT_NAME} --resume"
96
+ return f"{binary} chat --agent {KIRO_AGENT_NAME}"
97
+
98
+
99
+ def _kiro_teardown(_session_dir: Path) -> None:
100
+ """Restore the backed-up Kiro agent JSON."""
101
+ target = KIRO_AGENTS_DIR / f"{KIRO_AGENT_NAME}.json"
102
+ backup = target.with_suffix(target.suffix + _KIRO_BACKUP_SUFFIX)
103
+ if backup.exists():
104
+ os.replace(backup, target)
105
+
106
+
107
+ ADAPTER = AgentAdapter(
108
+ name="kiro",
109
+ binary="kiro-cli",
110
+ setup=_kiro_setup,
111
+ launch_cmd=_kiro_launch,
112
+ teardown=_kiro_teardown,
113
+ )