caudate-cli 0.1.87__tar.gz → 0.1.89__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 (190) hide show
  1. {caudate_cli-0.1.87/caudate_cli.egg-info → caudate_cli-0.1.89}/PKG-INFO +1 -1
  2. {caudate_cli-0.1.87 → caudate_cli-0.1.89/caudate_cli.egg-info}/PKG-INFO +1 -1
  3. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/caudate_cli.egg-info/SOURCES.txt +5 -0
  4. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/agent.py +31 -3
  5. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/agentic_loop.py +59 -5
  6. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/compaction.py +34 -2
  7. caudate_cli-0.1.89/core/goal.py +361 -0
  8. caudate_cli-0.1.89/core/goal_store.py +255 -0
  9. caudate_cli-0.1.89/core/outcomes.py +246 -0
  10. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/sandbox_prompt.py +36 -0
  11. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/slash_commands.py +262 -7
  12. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/executor.py +2 -0
  13. caudate_cli-0.1.89/execution/tools/decompose_goal_tool.py +217 -0
  14. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/llm/router.py +17 -0
  15. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/main.py +112 -1
  16. caudate_cli-0.1.89/memory/strategies.py +285 -0
  17. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/config.py +19 -0
  18. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/data.py +21 -0
  19. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/heads.py +27 -3
  20. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/observer.py +157 -3
  21. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/runtime.py +19 -1
  22. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/trainer.py +24 -0
  23. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/pyproject.toml +1 -1
  24. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/LICENSE +0 -0
  25. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/README.md +0 -0
  26. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/__init__.py +0 -0
  27. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/anthropic_compat.py +0 -0
  28. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/artifact_viewer.py +0 -0
  29. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/caudate_middleware.py +0 -0
  30. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/forge_bootstrapper_routes.py +0 -0
  31. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/forge_routes.py +0 -0
  32. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/forge_system_routes.py +0 -0
  33. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/openai_compat.py +0 -0
  34. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/server.py +0 -0
  35. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/api/storyboard_page.py +0 -0
  36. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/caudate_cli.egg-info/dependency_links.txt +0 -0
  37. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/caudate_cli.egg-info/entry_points.txt +0 -0
  38. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/caudate_cli.egg-info/requires.txt +0 -0
  39. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/caudate_cli.egg-info/top_level.txt +0 -0
  40. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/cognos_mcp/__init__.py +0 -0
  41. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/cognos_mcp/bridge.py +0 -0
  42. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/cognos_mcp/client.py +0 -0
  43. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/cognos_mcp/config.py +0 -0
  44. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/cognos_mcp/server.py +0 -0
  45. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/config.py +0 -0
  46. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/__init__.py +0 -0
  47. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/anthropic_auth.py +0 -0
  48. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/auto_onboarding.py +0 -0
  49. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/background.py +0 -0
  50. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/banner.py +0 -0
  51. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/bootstrap.py +0 -0
  52. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/citations.py +0 -0
  53. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/constitution.py +0 -0
  54. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/custom_commands.py +0 -0
  55. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/diff_viewer.py +0 -0
  56. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/export.py +0 -0
  57. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/file_refs.py +0 -0
  58. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/files.py +0 -0
  59. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/hooks.py +0 -0
  60. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/image.py +0 -0
  61. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/input.py +0 -0
  62. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/loop.py +0 -0
  63. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/memory_md.py +0 -0
  64. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/notifications.py +0 -0
  65. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/ownership.py +0 -0
  66. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/paste.py +0 -0
  67. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/permissions.py +0 -0
  68. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/permissions_auto.py +0 -0
  69. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/plan_mode.py +0 -0
  70. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/scheduler.py +0 -0
  71. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/schemas.py +0 -0
  72. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/session.py +0 -0
  73. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/settings.py +0 -0
  74. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/skills.py +0 -0
  75. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/statusline.py +0 -0
  76. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/subagent.py +0 -0
  77. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/thinking.py +0 -0
  78. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/updater.py +0 -0
  79. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/usage.py +0 -0
  80. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/core/worktree.py +0 -0
  81. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/__init__.py +0 -0
  82. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/plugins.py +0 -0
  83. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/__init__.py +0 -0
  84. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/agent_tool.py +0 -0
  85. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/agentic_tool.py +0 -0
  86. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/artifact_tool.py +0 -0
  87. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/ask_user_question_tool.py +0 -0
  88. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/base.py +0 -0
  89. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/calculator_tool.py +0 -0
  90. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/cognos_card_tool.py +0 -0
  91. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/cron_tool.py +0 -0
  92. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/datetime_tool.py +0 -0
  93. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/describe_image_tool.py +0 -0
  94. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/draw_tool.py +0 -0
  95. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/edit_image_tool.py +0 -0
  96. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/edit_tool.py +0 -0
  97. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/file_tool.py +0 -0
  98. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/find_anywhere_tool.py +0 -0
  99. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/forge_feature_tools.py +0 -0
  100. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/glob_tool.py +0 -0
  101. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/grep_tool.py +0 -0
  102. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/http_request_tool.py +0 -0
  103. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/load_skill_tool.py +0 -0
  104. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/longcat_avatar_tool.py +0 -0
  105. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/mcp_tool.py +0 -0
  106. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/notebook_tool.py +0 -0
  107. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/openapi_tool.py +0 -0
  108. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/plan_mode_tool.py +0 -0
  109. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/push_notification_tool.py +0 -0
  110. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/python_tool.py +0 -0
  111. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/respond_tool.py +0 -0
  112. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/sandbox_tool.py +0 -0
  113. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/search_tool.py +0 -0
  114. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/semantic_search_tool.py +0 -0
  115. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/shell_tool.py +0 -0
  116. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/speak_tool.py +0 -0
  117. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/storyboard_tool.py +0 -0
  118. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/system_info_tool.py +0 -0
  119. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/task_tool.py +0 -0
  120. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/think_tool.py +0 -0
  121. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/todo_tool.py +0 -0
  122. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/transcribe_audio_tool.py +0 -0
  123. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/update_memory_tool.py +0 -0
  124. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/web_fetch_tool.py +0 -0
  125. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/execution/tools/worktree_tool.py +0 -0
  126. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/llm/__init__.py +0 -0
  127. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/llm/fallback.py +0 -0
  128. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/llm/models.py +0 -0
  129. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/llm/models_dev.py +0 -0
  130. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/llm/provider.py +0 -0
  131. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/memory/__init__.py +0 -0
  132. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/memory/episodic.py +0 -0
  133. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/memory/procedural.py +0 -0
  134. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/memory/semantic.py +0 -0
  135. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/memory/working.py +0 -0
  136. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/__init__.py +0 -0
  137. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/auto_evolve.py +0 -0
  138. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/caudate.py +0 -0
  139. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/consolidator.py +0 -0
  140. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/encoder.py +0 -0
  141. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/forge_advisor.py +0 -0
  142. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/format.py +0 -0
  143. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/policy.py +0 -0
  144. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/scorer.py +0 -0
  145. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/nn/vision.py +0 -0
  146. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/personality/__init__.py +0 -0
  147. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/personality/engine.py +0 -0
  148. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/personality/identity.py +0 -0
  149. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/personality/inner_voice.py +0 -0
  150. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/personality/mood.py +0 -0
  151. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/planning/__init__.py +0 -0
  152. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/planning/dev_server.py +0 -0
  153. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/planning/forge_models.py +0 -0
  154. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/planning/orchestrator.py +0 -0
  155. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/planning/planner.py +0 -0
  156. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/planning/task_graph.py +0 -0
  157. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/reflection/__init__.py +0 -0
  158. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/reflection/meta_learner.py +0 -0
  159. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/reflection/reflector.py +0 -0
  160. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/setup.cfg +0 -0
  161. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_agentic_runaway.py +0 -0
  162. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_chat_format.py +0 -0
  163. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_citations.py +0 -0
  164. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_constrained_routing.py +0 -0
  165. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_contrastive_tool_head.py +0 -0
  166. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_feature_done_contract.py +0 -0
  167. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_feature_success_head.py +0 -0
  168. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_file_refs.py +0 -0
  169. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_forge_advisor.py +0 -0
  170. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_forge_force_stop.py +0 -0
  171. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_forge_models.py +0 -0
  172. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_forge_observer.py +0 -0
  173. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_forge_orchestrator_runner.py +0 -0
  174. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_json_salvage.py +0 -0
  175. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_llm_fallback.py +0 -0
  176. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_pi_compat.py +0 -0
  177. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_scheduler.py +0 -0
  178. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_settings.py +0 -0
  179. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_skills_lockfile.py +0 -0
  180. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_slash_commands.py +0 -0
  181. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_usage.py +0 -0
  182. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/tests/test_vram_estimates.py +0 -0
  183. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/ui/__init__.py +0 -0
  184. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/ui/display.py +0 -0
  185. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/voice/__init__.py +0 -0
  186. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/voice/conversation.py +0 -0
  187. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/voice/listener.py +0 -0
  188. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/voice/speaker.py +0 -0
  189. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/voice/stt.py +0 -0
  190. {caudate_cli-0.1.87 → caudate_cli-0.1.89}/voice/tts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caudate-cli
3
- Version: 0.1.87
3
+ Version: 0.1.89
4
4
  Summary: A local-first cognitive agent with a learned router (Caudate) and Claude-SDK-shaped tool palette.
5
5
  Author-email: Rave Manji <rahimtz93@googlemail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caudate-cli
3
- Version: 0.1.87
3
+ Version: 0.1.89
4
4
  Summary: A local-first cognitive agent with a learned router (Caudate) and Claude-SDK-shaped tool palette.
5
5
  Author-email: Rave Manji <rahimtz93@googlemail.com>
6
6
  License-Expression: MIT
@@ -40,12 +40,15 @@ core/diff_viewer.py
40
40
  core/export.py
41
41
  core/file_refs.py
42
42
  core/files.py
43
+ core/goal.py
44
+ core/goal_store.py
43
45
  core/hooks.py
44
46
  core/image.py
45
47
  core/input.py
46
48
  core/loop.py
47
49
  core/memory_md.py
48
50
  core/notifications.py
51
+ core/outcomes.py
49
52
  core/ownership.py
50
53
  core/paste.py
51
54
  core/permissions.py
@@ -77,6 +80,7 @@ execution/tools/calculator_tool.py
77
80
  execution/tools/cognos_card_tool.py
78
81
  execution/tools/cron_tool.py
79
82
  execution/tools/datetime_tool.py
83
+ execution/tools/decompose_goal_tool.py
80
84
  execution/tools/describe_image_tool.py
81
85
  execution/tools/draw_tool.py
82
86
  execution/tools/edit_image_tool.py
@@ -120,6 +124,7 @@ memory/__init__.py
120
124
  memory/episodic.py
121
125
  memory/procedural.py
122
126
  memory/semantic.py
127
+ memory/strategies.py
123
128
  memory/working.py
124
129
  nn/__init__.py
125
130
  nn/auto_evolve.py
@@ -231,16 +231,44 @@ class CognosAgent:
231
231
  self.llm.set_mood(self.personality.mood)
232
232
 
233
233
  # Wrap personality's system_prompt provider with goal-injection.
234
- # The slash command `/goal <text>` sets `self._goal`, which we
235
- # prepend to whatever the personality returns. Stored as a
236
- # plain attribute so the slash setter doesn't need a method.
234
+ # Legacy `self._goal` (free-text string) is preserved for
235
+ # backward-compat. Phase 2 introduces the structured
236
+ # `self.active_goal: Goal | None` managed by the new
237
+ # /goal slash commands + core.goal_store, persists across
238
+ # sessions. 2.4 will inject its `render_for_prompt()` block.
237
239
  self._goal: str = ""
240
+ try:
241
+ from core.goal import Goal as _Goal # noqa: F401 (type hint only)
242
+ except Exception:
243
+ pass
244
+ self.active_goal = None # type: "Goal | None"
245
+ # Auto-resume: if a previous session set an active goal pointer,
246
+ # load it so caudate boots back into that goal's context. Best-
247
+ # effort; missing / unreadable goals are silently ignored.
248
+ try:
249
+ from core.goal_store import get_store
250
+ self.active_goal = get_store().active()
251
+ except Exception as e:
252
+ logger.debug(f"active goal auto-resume failed: {e}")
238
253
  _personality_wrap = (
239
254
  self.personality.wrap_system_prompt if self.personality else None
240
255
  )
241
256
 
242
257
  def system_prompt_provider(base: str) -> str:
243
258
  wrapped = _personality_wrap(base) if _personality_wrap else base
259
+ # Phase 2.4: prefer the structured active_goal's render_for_prompt
260
+ # block when present — it carries the subtask tree, progress
261
+ # counts, and a "don't restart the plan" nudge that single-
262
+ # string goal injection can't express. Falls back to the
263
+ # legacy free-text string for users who set /goal <text>
264
+ # without going through `/goal new`.
265
+ ag = getattr(self, "active_goal", None)
266
+ if ag is not None:
267
+ try:
268
+ block = ag.render_for_prompt()
269
+ return f"{block}\n\n{wrapped}"
270
+ except Exception as e:
271
+ logger.debug(f"active_goal render failed: {e}")
244
272
  goal = (self._goal or "").strip()
245
273
  if not goal:
246
274
  return wrapped
@@ -151,6 +151,11 @@ class AgenticLoop:
151
151
  self.documents = documents or []
152
152
  self.last_citations = []
153
153
  self._turn_aborted_by_user = False
154
+ # Phase 1.2: accumulate outcome signals across the turn so we
155
+ # can pass a real reward to Caudate at turn end instead of the
156
+ # success/failure heuristic.
157
+ from core.outcomes import OutcomeCollector
158
+ self._outcome_collector = OutcomeCollector()
154
159
  await self.hooks.emit(HookEvent.USER_PROMPT_SUBMIT, {"message": user_message})
155
160
  self._start_turn(user_message, attachments=attachments or [])
156
161
  consecutive_errors = 0
@@ -198,22 +203,40 @@ class AgenticLoop:
198
203
  result_block = await self._execute_call(call)
199
204
  self._append_tool_result(result_block)
200
205
  self._notify_caudate_tool_use(call.name, was_error=result_block.is_error)
206
+ # Phase 1.2: feed each tool result into the outcome collector
207
+ try:
208
+ self._outcome_collector.on_tool_result(
209
+ name=call.name, ok=not result_block.is_error,
210
+ )
211
+ except Exception:
212
+ pass
201
213
  if self._turn_aborted_by_user:
202
214
  break
203
215
 
204
216
  if self._turn_aborted_by_user:
217
+ try:
218
+ self._outcome_collector.on_turn_aborted_by_user()
219
+ except Exception:
220
+ pass
205
221
  msg = "(turn aborted: user denied a tool call)"
206
222
  await self.hooks.emit(HookEvent.STOP, {"text": msg})
207
223
  self._notify_caudate_turn_end()
208
224
  return msg
209
225
 
210
226
  logger.warning(f"Agentic loop hit max_iterations={self.max_iterations}")
211
- self._notify_caudate_turn_end(reward=0.2) # hit cap → low reward
227
+ try:
228
+ self._outcome_collector.on_hit_max_iterations()
229
+ except Exception:
230
+ pass
231
+ self._notify_caudate_turn_end() # outcome_collector now drives the reward
212
232
  return "(max iterations reached)"
213
233
 
214
234
  async def run_streaming(self, user_message: str) -> AsyncIterator[StreamEvent]:
215
235
  """Run the loop streaming tokens/events as they arrive."""
216
236
  self._turn_aborted_by_user = False
237
+ # Phase 1.2: outcome collector for the streaming path too.
238
+ from core.outcomes import OutcomeCollector
239
+ self._outcome_collector = OutcomeCollector()
217
240
  self._start_turn(user_message)
218
241
  self._notify_caudate_turn_start(user_message)
219
242
  tools = self._caudate_constrained_tools(self.executor.tool_definitions())
@@ -253,6 +276,12 @@ class AgenticLoop:
253
276
  result_block = await self._execute_call(call)
254
277
  self._append_tool_result(result_block)
255
278
  self._notify_caudate_tool_use(call.name, was_error=result_block.is_error)
279
+ try:
280
+ self._outcome_collector.on_tool_result(
281
+ name=call.name, ok=not result_block.is_error,
282
+ )
283
+ except Exception:
284
+ pass
256
285
  yield StreamEvent(
257
286
  type="tool_result",
258
287
  tool_use_id=call.id,
@@ -262,6 +291,10 @@ class AgenticLoop:
262
291
  break
263
292
 
264
293
  if self._turn_aborted_by_user:
294
+ try:
295
+ self._outcome_collector.on_turn_aborted_by_user()
296
+ except Exception:
297
+ pass
265
298
  self._notify_caudate_turn_end()
266
299
  yield StreamEvent(
267
300
  type="message_stop",
@@ -270,6 +303,11 @@ class AgenticLoop:
270
303
  return
271
304
 
272
305
  logger.warning(f"Agentic loop (stream) hit max_iterations={self.max_iterations}")
306
+ try:
307
+ self._outcome_collector.on_hit_max_iterations()
308
+ except Exception:
309
+ pass
310
+ self._notify_caudate_turn_end()
273
311
 
274
312
  def reset(self) -> None:
275
313
  """Clear conversation history (new session)."""
@@ -764,13 +802,29 @@ class AgenticLoop:
764
802
  def _notify_caudate_turn_end(self, reward: float | None = None) -> None:
765
803
  if self.caudate is None:
766
804
  return
805
+ # Phase 1.2: drain the outcome collector. When signals fired,
806
+ # pass `outcome_reward ∈ [-1, +1]` and let the observer derive
807
+ # its native [0, 1] reward. When no signals fired (e.g. plain
808
+ # chat turn), fall back to the legacy heuristic.
809
+ outcome_reward = None
810
+ try:
811
+ coll = getattr(self, "_outcome_collector", None)
812
+ if coll is not None:
813
+ sig = coll.finalize()
814
+ if sig.is_informative():
815
+ outcome_reward = sig.reward()
816
+ logger.debug(f"caudate outcome signal: {sig.summary()}")
817
+ except Exception as e:
818
+ logger.debug(f"outcome collector finalize failed: {e}")
819
+
767
820
  try:
768
- if reward is None and self.caudate._pending is not None:
769
- # Heuristic if no explicit reward: 0.7 default,
770
- # 0.3 if any tool errored mid-turn.
821
+ if outcome_reward is None and reward is None and self.caudate._pending is not None:
822
+ # Legacy heuristic if neither outcome signals nor an
823
+ # explicit reward are available: 0.7 default, 0.3 if
824
+ # any tool errored mid-turn.
771
825
  had_err = getattr(self.caudate._pending, "_had_error", False)
772
826
  reward = 0.3 if had_err else 0.7
773
- self.caudate.on_turn_end(reward=reward)
827
+ self.caudate.on_turn_end(reward=reward, outcome_reward=outcome_reward)
774
828
  except Exception as e:
775
829
  logger.debug(f"caudate.on_turn_end failed: {e}")
776
830
 
@@ -23,10 +23,26 @@ asked for, what tools were used and their key results, any decisions made, and
23
23
  any open threads. Be concise (300 words max) but include concrete facts (file
24
24
  paths, values, names). Do NOT add commentary — just the summary.
25
25
 
26
- Conversation:
26
+ {goal_context}Conversation:
27
27
  {conversation}"""
28
28
 
29
29
 
30
+ # Phase 2.6: when an active goal exists, prepend its block to the
31
+ # compaction prompt so the summarizer LLM knows what context matters
32
+ # most. Subtask transitions and attempt outcomes are easy to lose in
33
+ # a generic "summarize this" pass; this nudge keeps them anchored.
34
+ _GOAL_CONTEXT_HEADER = """The agent is working on this active goal:
35
+ {goal_block}
36
+
37
+ When summarizing, EXPLICITLY preserve:
38
+ - Any subtask status transitions (which moved from pending → in_progress → done)
39
+ - Any subtask that was blocked + why
40
+ - Attempt outcomes that mattered (rewards, errors, unexpected results)
41
+ - Decisions made about the goal's scope or approach
42
+
43
+ """
44
+
45
+
30
46
  class ContextCompactor:
31
47
  """Summarizes older messages to fit a token budget."""
32
48
 
@@ -72,7 +88,23 @@ class ContextCompactor:
72
88
  conversation = "\n\n".join(
73
89
  f"[{m.get('role')}]: {_content_str(m)}" for m in to_summarize
74
90
  )
75
- prompt = COMPACT_PROMPT.format(conversation=conversation[:12000])
91
+ # Phase 2.6: pull the active goal context (if any) into the
92
+ # compactor's prompt so the LLM-driven summary preserves
93
+ # subtask transitions + outcomes instead of dropping them as
94
+ # "implementation noise".
95
+ goal_context = ""
96
+ try:
97
+ from core.goal_store import get_store
98
+ active = get_store().active()
99
+ if active is not None:
100
+ goal_block = active.render_for_prompt()
101
+ goal_context = _GOAL_CONTEXT_HEADER.format(goal_block=goal_block)
102
+ except Exception as e:
103
+ logger.debug(f"compaction goal-context lookup failed: {e}")
104
+ prompt = COMPACT_PROMPT.format(
105
+ conversation=conversation[:12000],
106
+ goal_context=goal_context,
107
+ )
76
108
 
77
109
  try:
78
110
  response = await self.llm.complete(prompt=prompt, caller="compaction")
@@ -0,0 +1,361 @@
1
+ """Agent-state Goal model (Phase 2.1).
2
+
3
+ A `Goal` is the persistent intent the agent is working toward across
4
+ turns and sessions. Unlike `planning.forge_models.ForgeProject` (which
5
+ is the SQLAlchemy project-management surface), `Goal` here is the
6
+ agent's working memory of "what we're ultimately trying to build /
7
+ fix / explore."
8
+
9
+ The schema is deliberately lean — JSON-serializable so it can live
10
+ under `~/.caudate/goals/<id>.json` without a database dependency.
11
+
12
+ A goal is the unit of resumption:
13
+ caudate --goal <id> ← reboot into the goal
14
+ /goal resume <id> ← swap to a different goal mid-session
15
+ /goal status ← see current subtask tree
16
+ /goal complete ← mark done (positive Phase 1 signal)
17
+ /goal abandon ← mark abandoned (negative signal)
18
+
19
+ Subsequent Phase 2 sub-steps wire this in:
20
+ 2.2 GoalStore (file persistence)
21
+ 2.3 slash commands
22
+ 2.4 system-prompt injection
23
+ 2.5 sub-task decomposition tool
24
+ 2.6 compaction-aware goal context
25
+ 2.7 CLI flag
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import uuid
31
+ from dataclasses import dataclass, field, asdict
32
+ from datetime import datetime, timezone
33
+ from enum import Enum
34
+ from typing import Any
35
+
36
+
37
+ # ---------------------------------------------------------------------
38
+ # Status enums — string-valued so they round-trip through JSON cleanly.
39
+ # ---------------------------------------------------------------------
40
+
41
+ class GoalStatus(str, Enum):
42
+ ACTIVE = "active"
43
+ PAUSED = "paused"
44
+ COMPLETED = "completed"
45
+ ABANDONED = "abandoned"
46
+
47
+
48
+ class SubtaskStatus(str, Enum):
49
+ PENDING = "pending"
50
+ IN_PROGRESS = "in_progress"
51
+ DONE = "done"
52
+ BLOCKED = "blocked"
53
+
54
+
55
+ class AttemptResult(str, Enum):
56
+ SUCCESS = "success"
57
+ PARTIAL = "partial"
58
+ ERROR = "error"
59
+
60
+
61
+ # ---------------------------------------------------------------------
62
+ # Subtask — one step in the goal's decomposition.
63
+ # ---------------------------------------------------------------------
64
+
65
+ @dataclass
66
+ class Subtask:
67
+ """A single step in the goal's decomposition tree."""
68
+
69
+ id: str
70
+ description: str
71
+ status: SubtaskStatus = SubtaskStatus.PENDING
72
+ # IDs of other subtasks that must complete before this one can start.
73
+ # Empty list = independently dispatchable (Phase 6 sub-agents will
74
+ # parallelize these).
75
+ depends_on: list[str] = field(default_factory=list)
76
+ # AttemptLog ids that targeted this subtask.
77
+ attempts: list[str] = field(default_factory=list)
78
+
79
+ @classmethod
80
+ def new(cls, description: str, depends_on: list[str] | None = None) -> "Subtask":
81
+ return cls(
82
+ id=str(uuid.uuid4()),
83
+ description=description,
84
+ depends_on=list(depends_on or []),
85
+ )
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "id": self.id,
90
+ "description": self.description,
91
+ "status": self.status.value,
92
+ "depends_on": list(self.depends_on),
93
+ "attempts": list(self.attempts),
94
+ }
95
+
96
+ @classmethod
97
+ def from_dict(cls, d: dict[str, Any]) -> "Subtask":
98
+ return cls(
99
+ id=str(d.get("id") or uuid.uuid4()),
100
+ description=str(d.get("description", "")),
101
+ status=SubtaskStatus(d.get("status", "pending")),
102
+ depends_on=list(d.get("depends_on") or []),
103
+ attempts=list(d.get("attempts") or []),
104
+ )
105
+
106
+
107
+ # ---------------------------------------------------------------------
108
+ # AttemptLog — a turn-by-turn record of work toward the goal.
109
+ # Links into Phase 1's outcome signal: `reward` is the same scalar
110
+ # `outcome_reward` Phase 1.1+1.2 produce.
111
+ # ---------------------------------------------------------------------
112
+
113
+ @dataclass
114
+ class AttemptLog:
115
+ """One turn's worth of work toward the goal."""
116
+
117
+ id: str
118
+ turn_index: int # ordinal across the goal's lifetime
119
+ timestamp: str # ISO 8601 UTC
120
+ subtask_id: str | None = None # which subtask was being worked on
121
+ tool_calls: list[str] = field(default_factory=list) # tool names in order
122
+ result: AttemptResult | None = None
123
+ reward: float | None = None # outcome_reward ∈ [-1, +1] from Phase 1
124
+ note: str = "" # one-liner from agent: "added Flask routes"
125
+
126
+ @classmethod
127
+ def new(
128
+ cls,
129
+ turn_index: int,
130
+ subtask_id: str | None = None,
131
+ tool_calls: list[str] | None = None,
132
+ result: AttemptResult | None = None,
133
+ reward: float | None = None,
134
+ note: str = "",
135
+ ) -> "AttemptLog":
136
+ return cls(
137
+ id=str(uuid.uuid4()),
138
+ turn_index=turn_index,
139
+ timestamp=datetime.now(timezone.utc).isoformat(),
140
+ subtask_id=subtask_id,
141
+ tool_calls=list(tool_calls or []),
142
+ result=result,
143
+ reward=reward,
144
+ note=note,
145
+ )
146
+
147
+ def to_dict(self) -> dict[str, Any]:
148
+ return {
149
+ "id": self.id,
150
+ "turn_index": self.turn_index,
151
+ "timestamp": self.timestamp,
152
+ "subtask_id": self.subtask_id,
153
+ "tool_calls": list(self.tool_calls),
154
+ "result": self.result.value if self.result else None,
155
+ "reward": self.reward,
156
+ "note": self.note,
157
+ }
158
+
159
+ @classmethod
160
+ def from_dict(cls, d: dict[str, Any]) -> "AttemptLog":
161
+ return cls(
162
+ id=str(d.get("id") or uuid.uuid4()),
163
+ turn_index=int(d.get("turn_index") or 0),
164
+ timestamp=str(d.get("timestamp") or datetime.now(timezone.utc).isoformat()),
165
+ subtask_id=d.get("subtask_id"),
166
+ tool_calls=list(d.get("tool_calls") or []),
167
+ result=AttemptResult(d["result"]) if d.get("result") else None,
168
+ reward=d.get("reward"),
169
+ note=str(d.get("note", "")),
170
+ )
171
+
172
+
173
+ # ---------------------------------------------------------------------
174
+ # Goal — the top-level persistent intent.
175
+ # ---------------------------------------------------------------------
176
+
177
+ @dataclass
178
+ class Goal:
179
+ """Persistent agent-state intent. The unit of resumption."""
180
+
181
+ id: str
182
+ intent: str # user's original ask
183
+ cwd: str # workspace path
184
+ status: GoalStatus = GoalStatus.ACTIVE
185
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
186
+ updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
187
+ subtasks: list[Subtask] = field(default_factory=list)
188
+ attempts: list[AttemptLog] = field(default_factory=list)
189
+ # Cross-link to a forge project if the user spun one up alongside
190
+ # this goal (planning/forge_models.ForgeProject). Optional —
191
+ # most goals won't have one.
192
+ forge_project_id: int | None = None
193
+ notes: str = ""
194
+
195
+ @classmethod
196
+ def new(cls, intent: str, cwd: str) -> "Goal":
197
+ return cls(
198
+ id=str(uuid.uuid4()),
199
+ intent=intent,
200
+ cwd=cwd,
201
+ )
202
+
203
+ # ── State queries ──────────────────────────────────────────────
204
+
205
+ def is_active(self) -> bool:
206
+ return self.status == GoalStatus.ACTIVE
207
+
208
+ def short_id(self) -> str:
209
+ """First 8 chars of the UUID — what we show in the statusline / picker."""
210
+ return self.id[:8]
211
+
212
+ def subtask_counts(self) -> dict[str, int]:
213
+ """{status: count} across all subtasks."""
214
+ out = {s.value: 0 for s in SubtaskStatus}
215
+ for st in self.subtasks:
216
+ out[st.status.value] = out.get(st.status.value, 0) + 1
217
+ return out
218
+
219
+ def progress_fraction(self) -> float:
220
+ """Fraction of subtasks marked done. 0.0 if none."""
221
+ if not self.subtasks:
222
+ return 0.0
223
+ done = sum(1 for st in self.subtasks if st.status == SubtaskStatus.DONE)
224
+ return done / len(self.subtasks)
225
+
226
+ def next_dispatchable(self) -> list[Subtask]:
227
+ """Subtasks that are pending AND all their dependencies are done.
228
+ Phase 6 sub-agents will dispatch from this list in parallel."""
229
+ done_ids = {st.id for st in self.subtasks if st.status == SubtaskStatus.DONE}
230
+ return [
231
+ st for st in self.subtasks
232
+ if st.status == SubtaskStatus.PENDING
233
+ and all(dep in done_ids for dep in st.depends_on)
234
+ ]
235
+
236
+ # ── Mutation helpers ───────────────────────────────────────────
237
+
238
+ def touch(self) -> None:
239
+ self.updated_at = datetime.now(timezone.utc).isoformat()
240
+
241
+ def add_subtask(self, description: str, depends_on: list[str] | None = None) -> Subtask:
242
+ st = Subtask.new(description, depends_on)
243
+ self.subtasks.append(st)
244
+ self.touch()
245
+ return st
246
+
247
+ def find_subtask(self, subtask_id: str) -> Subtask | None:
248
+ for st in self.subtasks:
249
+ if st.id == subtask_id or st.id.startswith(subtask_id):
250
+ return st
251
+ return None
252
+
253
+ def set_subtask_status(self, subtask_id: str, status: SubtaskStatus) -> bool:
254
+ st = self.find_subtask(subtask_id)
255
+ if st is None:
256
+ return False
257
+ st.status = status
258
+ self.touch()
259
+ return True
260
+
261
+ def add_attempt(self, attempt: AttemptLog) -> None:
262
+ self.attempts.append(attempt)
263
+ if attempt.subtask_id:
264
+ st = self.find_subtask(attempt.subtask_id)
265
+ if st is not None and attempt.id not in st.attempts:
266
+ st.attempts.append(attempt.id)
267
+ self.touch()
268
+
269
+ def complete(self) -> None:
270
+ self.status = GoalStatus.COMPLETED
271
+ self.touch()
272
+
273
+ def abandon(self, reason: str = "") -> None:
274
+ self.status = GoalStatus.ABANDONED
275
+ if reason:
276
+ self.notes = (self.notes + f"\nabandoned: {reason}").strip()
277
+ self.touch()
278
+
279
+ # ── Serialization ──────────────────────────────────────────────
280
+
281
+ def to_dict(self) -> dict[str, Any]:
282
+ return {
283
+ "id": self.id,
284
+ "intent": self.intent,
285
+ "cwd": self.cwd,
286
+ "status": self.status.value,
287
+ "created_at": self.created_at,
288
+ "updated_at": self.updated_at,
289
+ "subtasks": [st.to_dict() for st in self.subtasks],
290
+ "attempts": [a.to_dict() for a in self.attempts],
291
+ "forge_project_id": self.forge_project_id,
292
+ "notes": self.notes,
293
+ }
294
+
295
+ @classmethod
296
+ def from_dict(cls, d: dict[str, Any]) -> "Goal":
297
+ return cls(
298
+ id=str(d.get("id") or uuid.uuid4()),
299
+ intent=str(d.get("intent", "")),
300
+ cwd=str(d.get("cwd", "")),
301
+ status=GoalStatus(d.get("status", "active")),
302
+ created_at=str(d.get("created_at") or datetime.now(timezone.utc).isoformat()),
303
+ updated_at=str(d.get("updated_at") or datetime.now(timezone.utc).isoformat()),
304
+ subtasks=[Subtask.from_dict(s) for s in (d.get("subtasks") or [])],
305
+ attempts=[AttemptLog.from_dict(a) for a in (d.get("attempts") or [])],
306
+ forge_project_id=d.get("forge_project_id"),
307
+ notes=str(d.get("notes", "")),
308
+ )
309
+
310
+ # ── Display ────────────────────────────────────────────────────
311
+
312
+ def summary_line(self) -> str:
313
+ """One-liner for the picker / statusline."""
314
+ counts = self.subtask_counts()
315
+ n_done = counts.get("done", 0)
316
+ n_total = len(self.subtasks)
317
+ progress = f"{n_done}/{n_total}" if n_total else "—"
318
+ intent_short = self.intent[:60].replace("\n", " ")
319
+ return (
320
+ f"[{self.short_id()}] {self.status.value:9} "
321
+ f"{progress:>5} {intent_short!r}"
322
+ )
323
+
324
+ def render_for_prompt(self) -> str:
325
+ """Block injected into the system prompt by Phase 2.4. Compact
326
+ so it doesn't blow context."""
327
+ lines = [
328
+ "### Active goal",
329
+ f" ID: {self.short_id()}",
330
+ f" Intent: {self.intent}",
331
+ f" Cwd: {self.cwd}",
332
+ ]
333
+ if self.subtasks:
334
+ n_done = sum(1 for s in self.subtasks if s.status == SubtaskStatus.DONE)
335
+ lines.append(f" Subtasks ({n_done}/{len(self.subtasks)} done):")
336
+ glyph = {
337
+ SubtaskStatus.DONE: "✓",
338
+ SubtaskStatus.IN_PROGRESS: "◐",
339
+ SubtaskStatus.PENDING: "☐",
340
+ SubtaskStatus.BLOCKED: "⊘",
341
+ }
342
+ for st in self.subtasks:
343
+ lines.append(f" {glyph[st.status]} {st.description}")
344
+ else:
345
+ # Phase 2.5 nudge — agent's first move on a fresh goal
346
+ # should be DecomposeGoal so the tree persists.
347
+ lines.append(" Subtasks: none yet.")
348
+ lines.append(
349
+ " → As your FIRST tool call, call DecomposeGoal with the "
350
+ "subtask breakdown. That persists the plan across turns "
351
+ "and sessions (unlike TodoWrite which is per-turn)."
352
+ )
353
+ if self.attempts:
354
+ recent_reward = next(
355
+ (a.reward for a in reversed(self.attempts) if a.reward is not None),
356
+ None,
357
+ )
358
+ if recent_reward is not None:
359
+ lines.append(f" Last attempt reward: {recent_reward:+.2f}")
360
+ lines.append(" Pick up where the last turn left off — don't restart the plan.")
361
+ return "\n".join(lines)