devops-bot-sdk 1.6.24__tar.gz → 1.6.25__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 (155) hide show
  1. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/devops_bot_sdk.egg-info/SOURCES.txt +4 -0
  4. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/client.py +43 -118
  6. devops_bot_sdk-1.6.25/sdk/github_ingest.py +161 -0
  7. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/intake.py +182 -8
  8. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/plan_run.py +106 -18
  9. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/run_auto.py +105 -14
  10. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/api.py +23 -66
  11. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/security.py +5 -9
  12. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/server.py +14 -74
  13. devops_bot_sdk-1.6.25/sdk/vps_health.py +111 -0
  14. devops_bot_sdk-1.6.25/tests/test_intake_plan_review.py +177 -0
  15. devops_bot_sdk-1.6.25/tests/test_plan_new_command.py +140 -0
  16. devops_bot_sdk-1.6.25/tests/test_plan_run_activity.py +210 -0
  17. devops_bot_sdk-1.6.25/tests/test_task_file_format_doc.py +283 -0
  18. devops_bot_sdk-1.6.24/sdk/github_ingest.py +0 -570
  19. devops_bot_sdk-1.6.24/sdk/vps_health.py +0 -264
  20. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/README.md +0 -0
  21. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  22. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  23. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/devops_bot_sdk.egg-info/requires.txt +0 -0
  24. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  25. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/pyproject.toml +0 -0
  26. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agentd.py +0 -0
  27. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/__init__.py +0 -0
  28. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/activity.py +0 -0
  29. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/builder.py +0 -0
  30. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/chat.py +0 -0
  31. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/connectors.py +0 -0
  32. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/engines/__init__.py +0 -0
  33. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/engines/claude_code.py +0 -0
  34. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/engines/http.py +0 -0
  35. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/engines/mcp.py +0 -0
  36. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/engines/n8n.py +0 -0
  37. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/engines/shell.py +0 -0
  38. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/integrations.py +0 -0
  39. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/inventory.py +0 -0
  40. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/library/__init__.py +0 -0
  41. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/manifest.py +0 -0
  42. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/prompt_studio.py +0 -0
  43. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/prompt_template.py +0 -0
  44. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/prompt_vars.py +0 -0
  45. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/run_log.py +0 -0
  46. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/schedules.py +0 -0
  47. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/secrets.py +0 -0
  48. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/ticket_run.py +0 -0
  49. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/usage.py +0 -0
  50. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/agents/varstore.py +0 -0
  51. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/cli.py +0 -0
  52. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/collectors/__init__.py +0 -0
  53. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/collectors/files.py +0 -0
  54. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/collectors/process.py +0 -0
  55. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/collectors/screenshot.py +0 -0
  56. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/config.py +0 -0
  57. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/crucial.py +0 -0
  58. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/departments.py +0 -0
  59. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/design_verify/__init__.py +0 -0
  60. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/design_verify/assets.py +0 -0
  61. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/design_verify/bootstrap.py +0 -0
  62. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/design_verify/browser.py +0 -0
  63. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/design_verify/compare.py +0 -0
  64. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/design_verify/loop.py +0 -0
  65. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/exceptions.py +0 -0
  66. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/formats.py +0 -0
  67. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/git_ops.py +0 -0
  68. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/graph_recon.py +0 -0
  69. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/graphify.py +0 -0
  70. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/hooks/__init__.py +0 -0
  71. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/hooks/crucial_guard.py +0 -0
  72. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ipc/__init__.py +0 -0
  73. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ipc/electron_bridge.py +0 -0
  74. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ipc/handlers.py +0 -0
  75. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/local_exec.py +0 -0
  76. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/models/__init__.py +0 -0
  77. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/models/envelope.py +0 -0
  78. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/models/requests.py +0 -0
  79. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/models/responses.py +0 -0
  80. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/models/snapshots.py +0 -0
  81. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/phase_timer.py +0 -0
  82. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/platform_compat.py +0 -0
  83. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/py.typed +0 -0
  84. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/scheduler.py +0 -0
  85. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/sse.py +0 -0
  86. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/task_file.py +0 -0
  87. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/test.py +0 -0
  88. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/test_pipeline.py +0 -0
  89. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ticket_fields.py +0 -0
  90. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/__init__.py +0 -0
  91. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/dashboard.py +0 -0
  92. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/health.py +0 -0
  93. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/ip_allowlist.py +0 -0
  94. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/jobs.py +0 -0
  95. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/models.py +0 -0
  96. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/runner.py +0 -0
  97. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/service.py +0 -0
  98. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/session.py +0 -0
  99. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/setup_manager.py +0 -0
  100. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/agents.html +0 -0
  101. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/app.js +0 -0
  102. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/chat.html +0 -0
  103. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/custom-agents.html +0 -0
  104. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/default-agents.html +0 -0
  105. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/index.html +0 -0
  106. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/login.html +0 -0
  107. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/runs.html +0 -0
  108. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/schedules.html +0 -0
  109. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/setup.html +0 -0
  110. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/styles.css +0 -0
  111. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/ui/static/tickets.html +0 -0
  112. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/sdk/updater.py +0 -0
  113. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/setup.cfg +0 -0
  114. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_activity.py +0 -0
  115. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_activity_tracking.py +0 -0
  116. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_chat_features.py +0 -0
  117. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_listing.py +0 -0
  118. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_prompt_async.py +0 -0
  119. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_prompt_models.py +0 -0
  120. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_run_ticket.py +0 -0
  121. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_schedules.py +0 -0
  122. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agent_workspace_settings.py +0 -0
  123. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_agents_connectors_builder.py +0 -0
  124. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_allowed_tools_config.py +0 -0
  125. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_auto_agent_dispatch.py +0 -0
  126. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_client_jira_projects.py +0 -0
  127. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_config_token_diagnostics.py +0 -0
  128. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_configure_token_owner.py +0 -0
  129. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_console_encoding.py +0 -0
  130. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_departments.py +0 -0
  131. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_epic_decompose_run.py +0 -0
  132. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_epic_inherited_agent_id.py +0 -0
  133. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_epic_session_isolation.py +0 -0
  134. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_formats.py +0 -0
  135. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_git_action.py +0 -0
  136. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_github_ingest.py +0 -0
  137. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_graph_recon.py +0 -0
  138. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_graphify_build.py +0 -0
  139. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_graphify_hook_install.py +0 -0
  140. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_health.py +0 -0
  141. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_otp_login.py +0 -0
  142. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_phase_timer.py +0 -0
  143. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_recon_session_isolation.py +0 -0
  144. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_run_auto_agent_only.py +0 -0
  145. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_run_outputs.py +0 -0
  146. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_schedule_api.py +0 -0
  147. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_scheduler.py +0 -0
  148. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_service_token_warning.py +0 -0
  149. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_task_file_and_plan_run.py +0 -0
  150. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_ticket_fields.py +0 -0
  151. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_ticket_run_resolve_by_id.py +0 -0
  152. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_ticket_speed_fixes.py +0 -0
  153. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_update_timer.py +0 -0
  154. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_updater_extras.py +0 -0
  155. {devops_bot_sdk-1.6.24 → devops_bot_sdk-1.6.25}/tests/test_vps_health.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.24
3
+ Version: 1.6.25
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.24
3
+ Version: 1.6.25
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -130,8 +130,11 @@ tests/test_graph_recon.py
130
130
  tests/test_graphify_build.py
131
131
  tests/test_graphify_hook_install.py
132
132
  tests/test_health.py
133
+ tests/test_intake_plan_review.py
133
134
  tests/test_otp_login.py
134
135
  tests/test_phase_timer.py
136
+ tests/test_plan_new_command.py
137
+ tests/test_plan_run_activity.py
135
138
  tests/test_recon_session_isolation.py
136
139
  tests/test_run_auto_agent_only.py
137
140
  tests/test_run_outputs.py
@@ -139,6 +142,7 @@ tests/test_schedule_api.py
139
142
  tests/test_scheduler.py
140
143
  tests/test_service_token_warning.py
141
144
  tests/test_task_file_and_plan_run.py
145
+ tests/test_task_file_format_doc.py
142
146
  tests/test_ticket_fields.py
143
147
  tests/test_ticket_run_resolve_by_id.py
144
148
  tests/test_ticket_speed_fixes.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.6.24
3
+ Version: 1.6.25
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -34,7 +34,7 @@ Rules:
34
34
  - All data egress through submit_webhook only
35
35
  """
36
36
 
37
- __version__ = "1.6.24" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
37
+ __version__ = "1.6.25" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
38
38
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
39
39
 
40
40
  from sdk.client import BackendClient
@@ -896,17 +896,11 @@ class BackendClient:
896
896
  return "pending"
897
897
 
898
898
  async def github_token_record(self) -> dict | None:
899
- """GET /ml-api/github/token → the caller's WHOLE GitHub link record.
899
+ """GET /ml-api/github/token → the full record for the caller's GitHub link.
900
900
 
901
- Returns the ``data`` object — ``{id, userId, githubId, username,
902
- accessToken, scope, …}`` — or None when this user has no GitHub link, the
903
- token is rejected, or the backend is unreachable. Inherently per-user: the
904
- ML backend resolves WHICH link to return from the ``co_`` token, so there
905
- is no list-all form.
906
-
907
- ``github_token()`` below is the accessToken-only view of this. The daily
908
- PR-ingest cron (``sdk.github_ingest``) needs ``username`` too, which is why
909
- the full record is exposed. Best-effort — never raises.
901
+ ``{id, userId, githubId, username, accessToken, scope, createdAt, updatedAt}``
902
+ or None when the user has no GitHub link, the token is rejected, or the
903
+ backend is unreachable. Best-effort: never raises.
910
904
  """
911
905
  from sdk.config import BE_DEFAULT_URL
912
906
 
@@ -923,7 +917,7 @@ class BackendClient:
923
917
  if resp.status_code >= 400:
924
918
  return None
925
919
  return (resp.json() or {}).get("data") or None
926
- except Exception: # noqa: BLE001
920
+ except Exception:
927
921
  return None
928
922
 
929
923
  async def github_token(self) -> str | None:
@@ -933,121 +927,52 @@ class BackendClient:
933
927
  endpoint lives on the ML backend (devopsbot-be), authorised by the same
934
928
  co_ token. Best-effort: returns None on any failure so a run without a
935
929
  connected GitHub account still proceeds (clone/PR steps just won't auth).
936
-
937
- Memoized for _GITHUB_TOKEN_TTL_S. The auto-loop asks for this once per
938
- ticket and again inside epic/story handling, so a long queue made the same
939
- round-trip dozens of times for a value that does not change between
940
- tickets. A TTL rather than a permanent cache so a token refreshed on the
941
- backend is picked up without restarting the daemon; a failed fetch is not
942
- cached, so a transient outage cannot pin None for the whole window.
943
930
  """
944
- import time as _time
945
-
946
- now = _time.monotonic()
947
- cached = _GITHUB_TOKEN_CACHE.get(self._token)
948
- if cached and now < cached[1]:
949
- return cached[0]
950
931
  record = await self.github_token_record()
951
- token = (record or {}).get("accessToken") or None
952
- if token:
953
- _GITHUB_TOKEN_CACHE[self._token] = (token, now + _GITHUB_TOKEN_TTL_S)
954
- return token
932
+ return (record or {}).get("accessToken") or None
955
933
 
956
- async def ingest_github_prs(
957
- self,
958
- *,
959
- github_username: str,
960
- github_access_token: str,
961
- since: str | None = None,
962
- user_id: str = "",
963
- ) -> dict | None:
964
- """POST /api/v1/github/prs — queue PR ingestion for THIS user on the backend.
965
-
966
- This is the daily GitHub ingestion trigger. It runs on the AI backend
967
- (``devopsbot-ai.apiswagger.co.uk`` — ``self._url``, the same base every other
968
- ``/api/v1`` call uses), NOT the ML gateway: the backend owns the LLM
969
- summarise, the downstream daily-summary forward, and the Qdrant upsert.
970
- The SDK only decides WHEN, because only this machine knows this user's
971
- schedule and its own last-success watermark.
972
-
973
- ``since`` is an ISO date (``YYYY-MM-DD``) → the backend's
974
- ``GetUserPRsRequest.date_from``. Omitted means "today", which is what a
975
- never-missed daily run wants; ``sdk.github_ingest`` sends the catch-up
976
- watermark instead so a machine that was off does not silently lose days.
977
-
978
- The credentials are sent in the body because that is what the endpoint
979
- accepts today. The ``Authorization: co_`` header goes with every request
980
- regardless, so once the backend resolves the GitHub link server-side from
981
- that token (see docs/GITHUB_SCHEDULING_SDK_MIGRATION.md §3 step 1) it can
982
- ignore the body fields without breaking this client.
983
-
984
- Returns the response JSON (``{job_id, …}``) or None on any failure —
985
- never raises into the cron.
934
+ async def ingest_github_prs(self, since: str | None = None) -> dict:
935
+ """POST {AI}/api/v1/github/prs — trigger this user's daily PR ingestion.
936
+
937
+ The backend resolves the caller's GitHub link server-side from the co_
938
+ token in ``Authorization`` (see ``docs/GITHUB_SCHEDULING_SDK_MIGRATION.md``);
939
+ ``githubUsername``/``githubAccessToken`` below are placeholders kept only
940
+ for backends that predate that resolution and are otherwise ignored.
941
+ ``since`` (ISO-8601 date) scopes the fetch — omit for "today".
942
+
943
+ Returns the backend's ``{status, job_id, status_url, ...}`` envelope.
944
+ Raises BackendUnreachable / HTTP errors — the github_ingest cron catches
945
+ and logs these itself rather than treating them as success.
986
946
  """
987
- payload = [{
988
- "userId": user_id or self._user_id_from_token(),
989
- "githubUsername": github_username,
990
- "githubAccessToken": github_access_token,
991
- "apiTokenHash": self._token, # downstream daily-summary Authorization
947
+ body = [{
948
+ "userId": self._user_id_from_token(),
949
+ "githubUsername": "",
950
+ "githubAccessToken": "",
951
+ "apiTokenHash": self._token,
952
+ "date_from": since,
992
953
  }]
993
- if since:
994
- payload[0]["date_from"] = since
995
- self.last_ingest_error = {}
996
- try:
997
- async with httpx.AsyncClient(timeout=60.0) as client:
998
- resp = await client.post(
999
- self._url("/api/v1/github/prs"), json=payload, headers=self._headers,
1000
- )
1001
- if resp.status_code >= 400:
1002
- body = resp.text[:500]
1003
- logger.warning("client.ingest_github_prs failed status=%s body=%s",
1004
- resp.status_code, body[:200])
1005
- self.last_ingest_error = {
1006
- "status": resp.status_code,
1007
- "body": body,
1008
- "retryable": is_retryable_backend_error(resp.status_code, body),
1009
- }
1010
- return None
1011
- return resp.json()
1012
- except Exception as exc: # noqa: BLE001
1013
- logger.warning("client.ingest_github_prs error err=%s", exc)
1014
- # A transport failure (timeout, connection reset, DNS blip) is as
1015
- # retryable as an exhausted pool — the backend may simply be busy.
1016
- self.last_ingest_error = {"status": 0, "body": str(exc)[:500], "retryable": True}
1017
- return None
1018
-
1019
- async def get_task_status(self, task_id: str) -> str | None:
1020
- """GET {BE}/tasks-ml/{id} → the ticket's CURRENT board status, or None.
954
+ async with httpx.AsyncClient(timeout=30.0) as client:
955
+ resp = await client.post(
956
+ self._url("/api/v1/github/prs"), json=body, headers=self._headers,
957
+ )
958
+ _check_status(resp.status_code, self._url("/api/v1/github/prs"))
959
+ return resp.json()
1021
960
 
1022
- The authority on "did this ticket actually move". The local run finalizes
1023
- server-side and then reads back a session status, but a session reporting
1024
- ``completed`` is not evidence that the BOARD transitioned — the two are
1025
- separate systems and the Jira-side transition can lag or fail on its own.
1026
- Reading the ticket is the only way to tell them apart.
961
+ async def report_vps_health(self, payload: dict) -> dict:
962
+ """PATCH {AI}/api/v1/vps/health — heartbeat this machine's resource usage.
1027
963
 
1028
- Returns None when the ticket cannot be read (network, auth, 404). Callers
1029
- must treat None as "unknown", NOT as "not Done" — forcing a status on an
1030
- unreadable ticket would be guessing.
964
+ Feeds the backend's ``contabo_allocation`` row (VPS health shown in chat /
965
+ activity reports) — a live GET of the same path answers on-demand checks,
966
+ this PATCH is what keeps that data from going stale. Raises on failure
967
+ (BackendUnreachable / HTTP error); the vps_health cron logs and continues
968
+ rather than crashing the loop.
1031
969
  """
1032
- from sdk.config import BE_DEFAULT_URL
1033
-
1034
- url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml/{task_id}"
1035
- headers = {**self._headers, "accept": "*/*", "developer-name": "null", "product": ""}
1036
- try:
1037
- async with httpx.AsyncClient(timeout=20.0) as client:
1038
- resp = await client.get(url, headers=headers)
1039
- if resp.status_code >= 400:
1040
- logger.warning("client.get_task_status failed task=%s status=%s",
1041
- task_id, resp.status_code)
1042
- return None
1043
- rec = (resp.json() or {}).get("data") or {}
1044
- if isinstance(rec, dict):
1045
- rec = rec.get("record") or rec
1046
- status = str(rec.get("status") or "").strip()
1047
- return status or None
1048
- except Exception as exc: # noqa: BLE001
1049
- logger.warning("client.get_task_status error task=%s err=%s", task_id, exc)
1050
- return None
970
+ async with httpx.AsyncClient(timeout=30.0) as client:
971
+ resp = await client.request(
972
+ "PATCH", self._url("/api/v1/vps/health"), json=payload, headers=self._headers,
973
+ )
974
+ _check_status(resp.status_code, self._url("/api/v1/vps/health"))
975
+ return resp.json()
1051
976
 
1052
977
  async def move_to_backlog(self, task_id: str) -> bool:
1053
978
  """PUT {BE}/tasks-ml/{id} status=backlog (best-effort).
@@ -0,0 +1,161 @@
1
+ """Per-machine daily GitHub PR ingestion trigger.
2
+
3
+ See ``docs/GITHUB_SCHEDULING_SDK_MIGRATION.md`` (Option A) in ``devops-bot-ai``:
4
+ the SDK owns the trigger only — one authenticated POST a day to
5
+ ``/api/v1/github/prs``; the backend keeps the LLM summarise + SQL + Qdrant work.
6
+
7
+ Slot-based, not interval-based: ticks every 15 min and fires once the UTC clock
8
+ has passed ``AGENTOS_GITHUB_INGEST_AT`` (default ``13:30``) *and* no success is
9
+ recorded for today. A machine that's asleep at the slot catches up on wake
10
+ instead of silently losing that day's run (a central Celery-beat schedule fires
11
+ regardless of machine state; a per-VPS cron does not — see the doc's §6).
12
+
13
+ Catch-up watermark: ``~/.agentos/github_ingest.json`` holds ``last_success_at``.
14
+ The request sends ``since = last_success_at`` (capped by
15
+ ``AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS``, default 7), never a bare "today". A
16
+ failed run does NOT advance the watermark, so the window is re-covered next tick.
17
+
18
+ Disable with ``AGENTOS_GITHUB_INGEST=0``.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ from datetime import date, datetime, timedelta, timezone
27
+ from pathlib import Path
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _ENABLED_ENV = "AGENTOS_GITHUB_INGEST"
32
+ _AT_ENV = "AGENTOS_GITHUB_INGEST_AT"
33
+ _LOOKBACK_ENV = "AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS"
34
+ _DEFAULT_AT = "13:30" # UTC — the old beat slot (18:30 PKT)
35
+ _DEFAULT_LOOKBACK = 7
36
+ _TICK_SECONDS = 15 * 60 # check the slot every 15 min
37
+
38
+ _STATE_FILE = Path.home() / ".agentos" / "github_ingest.json"
39
+
40
+
41
+ def enabled() -> bool:
42
+ return os.environ.get(_ENABLED_ENV, "1").strip() not in ("0", "false", "False")
43
+
44
+
45
+ def _target_time() -> tuple[int, int]:
46
+ raw = os.environ.get(_AT_ENV, "") or _DEFAULT_AT
47
+ try:
48
+ hh, mm = raw.split(":", 1)
49
+ return int(hh) % 24, int(mm) % 60
50
+ except (ValueError, TypeError):
51
+ return 13, 30
52
+
53
+
54
+ def lookback_days() -> int:
55
+ try:
56
+ return max(1, int(os.environ.get(_LOOKBACK_ENV, "") or _DEFAULT_LOOKBACK))
57
+ except ValueError:
58
+ return _DEFAULT_LOOKBACK
59
+
60
+
61
+ def _load_state() -> dict:
62
+ try:
63
+ return json.loads(_STATE_FILE.read_text(encoding="utf-8"))
64
+ except Exception: # noqa: BLE001 — first run, corrupt file, etc.
65
+ return {}
66
+
67
+
68
+ def _save_state(state: dict) -> None:
69
+ try:
70
+ _STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
71
+ _STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
72
+ except Exception as exc: # noqa: BLE001 — a failed write just costs one retry
73
+ logger.warning("github_ingest.state_write_failed: %s", exc)
74
+
75
+
76
+ def _since(state: dict) -> str:
77
+ """The ``date_from`` to send — last success, capped, never a bare 'today'."""
78
+ last = state.get("last_success_at")
79
+ floor = date.today() - timedelta(days=lookback_days())
80
+ if not last:
81
+ return floor.isoformat()
82
+ try:
83
+ last_date = datetime.fromisoformat(last).date()
84
+ except ValueError:
85
+ return floor.isoformat()
86
+ return max(last_date, floor).isoformat()
87
+
88
+
89
+ def _due_now(state: dict, now: datetime) -> bool:
90
+ last = state.get("last_success_at")
91
+ if last:
92
+ try:
93
+ if datetime.fromisoformat(last).date() >= now.date():
94
+ return False # already succeeded today
95
+ except ValueError:
96
+ pass
97
+ hh, mm = _target_time()
98
+ return (now.hour, now.minute) >= (hh, mm)
99
+
100
+
101
+ async def run_once(force: bool = False) -> dict:
102
+ """Trigger ingestion now (unconditionally if ``force``). Updates the watermark
103
+ on success only. Never raises — the outcome is always returned, never thrown.
104
+ """
105
+ from sdk.client import BackendClient
106
+
107
+ state = _load_state()
108
+ if not force and not _due_now(state, datetime.now(timezone.utc)):
109
+ return {"ok": True, "skipped": "not_due", **state}
110
+
111
+ since = _since(state)
112
+ try:
113
+ client = BackendClient.from_config()
114
+ result = await client.ingest_github_prs(since=since)
115
+ # BackendClient._check_status only raises on 401/426/5xx — a 4xx business
116
+ # error (no linked GitHub account, validation failure, ...) comes back as
117
+ # a normal JSON body with no exception. Only a genuine accepted-job
118
+ # response advances the watermark; anything else is a failure that must
119
+ # be retried, not silently marked done.
120
+ if str(result.get("status", "")).lower() != "accepted" or not result.get("job_id"):
121
+ raise RuntimeError(result.get("detail") or f"unexpected response: {result}")
122
+ state["last_success_at"] = datetime.now(timezone.utc).date().isoformat()
123
+ state["last_outcome"] = {"ok": True, "since": since, "result": result}
124
+ _save_state(state)
125
+ logger.info("github_ingest.triggered since=%s", since)
126
+ return state["last_outcome"]
127
+ except Exception as exc: # noqa: BLE001 — failure must not advance the watermark
128
+ outcome = {"ok": False, "since": since, "error": str(exc)}
129
+ state["last_outcome"] = outcome
130
+ _save_state(state)
131
+ logger.warning("github_ingest.failed: %s", exc)
132
+ return outcome
133
+
134
+
135
+ def status() -> dict:
136
+ """Config + watermark + last outcome — the ``GET /api/ui/github-ingest`` body."""
137
+ hh, mm = _target_time()
138
+ state = _load_state()
139
+ return {
140
+ "enabled": enabled(),
141
+ "at_utc": f"{hh:02d}:{mm:02d}",
142
+ "lookback_days": lookback_days(),
143
+ "last_success_at": state.get("last_success_at"),
144
+ "last_outcome": state.get("last_outcome"),
145
+ }
146
+
147
+
148
+ async def run_forever() -> None:
149
+ while True:
150
+ try:
151
+ await run_once()
152
+ except Exception as exc: # noqa: BLE001 — the tick loop must never die
153
+ logger.warning("github_ingest.tick_failed: %s", exc)
154
+ await asyncio.sleep(_TICK_SECONDS)
155
+
156
+
157
+ def start() -> "asyncio.Task | None":
158
+ if not enabled():
159
+ logger.info("github_ingest.disabled")
160
+ return None
161
+ return asyncio.create_task(run_forever(), name="github-ingest-cron")
@@ -115,8 +115,124 @@ async def finalize(conversation_id: str, *, requirements: dict[str, Any],
115
115
  )
116
116
 
117
117
 
118
+ async def build_plan(conversation_id: str, *, requirements: dict[str, Any],
119
+ history: list[dict[str, Any]] | None = None,
120
+ plan: dict[str, Any] | None = None,
121
+ feedback: str | None = None) -> dict[str, Any]:
122
+ """Draft — or revise — the plan WITHOUT creating anything.
123
+
124
+ The step `finalize` skips. `finalize` builds a plan and commits it in one
125
+ call, which is right for "just make the tickets" but leaves no way to read
126
+ the plan first; the control panel has always had the review step and the CLI
127
+ had not. Pass BOTH `plan` and `feedback` to revise: the revise prompt requires
128
+ that anything the feedback does not mention comes back unchanged.
129
+ """
130
+ payload: dict[str, Any] = {
131
+ "conversation_id": conversation_id,
132
+ "requirements": requirements,
133
+ "history": history or [],
134
+ }
135
+ if plan is not None:
136
+ payload["plan"] = plan
137
+ if feedback:
138
+ payload["feedback"] = feedback
139
+ return await _post(
140
+ f"/api/v1/tickets/intake/{conversation_id}/plan",
141
+ payload, _FINALIZE_TIMEOUT_S,
142
+ )
143
+
144
+
145
+ async def create(conversation_id: str, *, plan: dict[str, Any],
146
+ product_id: str, requirements: dict[str, Any] | None = None,
147
+ pv_id: str | None = None,
148
+ project: dict[str, Any] | None = None,
149
+ assignee_email: str | None = None,
150
+ autorun: bool = True) -> dict[str, Any]:
151
+ """Commit a REVIEWED plan — the plan is sent back rather than rebuilt.
152
+
153
+ That is deliberate and matches the panel: what a person approved on screen is
154
+ exactly what gets created, edits included. Rebuilding it here would quietly
155
+ create something the reviewer never saw.
156
+ """
157
+ payload: dict[str, Any] = {
158
+ "conversation_id": conversation_id,
159
+ "plan": plan,
160
+ "requirements": requirements or {},
161
+ "product_id": product_id,
162
+ "autorun": autorun,
163
+ }
164
+ if pv_id:
165
+ payload["pv_id"] = pv_id
166
+ if project:
167
+ payload["project"] = project
168
+ if assignee_email:
169
+ payload["assignee_email"] = assignee_email
170
+ return await _post(
171
+ f"/api/v1/tickets/intake/{conversation_id}/create",
172
+ payload, _FINALIZE_TIMEOUT_S,
173
+ )
174
+
175
+
118
176
  # ── Terminal loop ─────────────────────────────────────────────────────────
119
177
 
178
+ def render_plan(plan: dict[str, Any]) -> str:
179
+ """The plan as text, laid out the way the User Story format reads.
180
+
181
+ What the plan step returns is the SHAPE of the work — per epic an overview,
182
+ scope in/out, dependencies and acceptance criteria; per story a title, a
183
+ summary and points. The full four-section `## User Story` body is written at
184
+ CREATE time by the backend's generator (which injects the same
185
+ `USER_STORY_FORMAT` this SDK ships). So this renders the review copy under the
186
+ same headings, and says where the rest arrives — rather than printing a raw
187
+ dict and calling it a user story.
188
+ """
189
+ out: list[str] = []
190
+ title = str(plan.get("title") or "(untitled plan)")
191
+ out.append(f"# {title}")
192
+ if plan.get("summary"):
193
+ out.append(f"\n{plan['summary']}")
194
+
195
+ for dept in plan.get("departments") or []:
196
+ out.append(f"\n\n── Department: {dept.get('tag') or '—'} ──")
197
+ if dept.get("objective"):
198
+ out.append(f"Objective: {dept['objective']}")
199
+ for i, epic in enumerate(dept.get("epics") or [], 1):
200
+ out.append(f"\nEPIC {i}: {epic.get('title') or '—'}")
201
+ if epic.get("overview"):
202
+ out.append(f" {epic['overview']}")
203
+ for label, key in (("Scope IN", "scope_in"), ("Scope OUT", "scope_out"),
204
+ ("Dependencies", "dependencies")):
205
+ vals = epic.get(key) or []
206
+ if vals:
207
+ out.append(f" {label}: " + "; ".join(str(v) for v in vals))
208
+ crit = epic.get("acceptance_criteria") or []
209
+ if crit:
210
+ out.append(" ## Acceptance Criteria")
211
+ out.extend(f" - {c}" for c in crit)
212
+ stories = epic.get("stories") or []
213
+ out.append(f" ## User Stories ({len(stories)})")
214
+ for j, st in enumerate(stories, 1):
215
+ pts = st.get("points")
216
+ out.append(f" {i}.{j} {st.get('title') or st.get('summary') or '—'}"
217
+ + (f" [{pts} pts]" if pts else ""))
218
+ if st.get("summary") and st.get("title"):
219
+ out.append(f" {st['summary']}")
220
+ out.append("\n\nEach story's full `## User Story / ## Acceptance Criteria / "
221
+ "## Notes & Constraints / ## Definition of Done` body is written when "
222
+ "the tickets are created.")
223
+ return "\n".join(out)
224
+
225
+
226
+ def _plan_counts(plan: dict[str, Any]) -> tuple[int, int]:
227
+ """(epics, stories) across every department in a plan."""
228
+ epics = stories = 0
229
+ for dept in plan.get("departments") or []:
230
+ eps = dept.get("epics") or []
231
+ epics += len(eps)
232
+ stories += sum(len(e.get("stories") or []) for e in eps)
233
+ return epics, stories
234
+
235
+
120
236
  def _print_status(requirements: dict[str, Any], missing: list[str]) -> None:
121
237
  """Show what is known and what is still needed.
122
238
 
@@ -156,10 +272,16 @@ def chat_cli(argv: list[str]) -> None:
156
272
  autorun = "--no-autorun" not in argv
157
273
 
158
274
  print("agentos chat — describe the work you want done.")
159
- print(" Answer the questions; type 'done' to build the ticket, 'quit' to abandon.\n")
275
+ print(" Answer the questions, then:")
276
+ print(" plan build the plan and read it (creates nothing)")
277
+ print(" revise <change> adjust the plan")
278
+ print(" show re-print the current plan")
279
+ print(" create / done create the tickets")
280
+ print(" quit abandon; nothing is created\n")
160
281
 
161
282
  history: list[dict[str, Any]] = []
162
283
  requirements: dict[str, Any] = {}
284
+ plan: dict[str, Any] | None = None
163
285
  conversation_id: str | None = None
164
286
  index = 0
165
287
 
@@ -177,7 +299,46 @@ def chat_cli(argv: list[str]) -> None:
177
299
  print("abandoned — nothing was created.")
178
300
  return
179
301
 
180
- if message.lower() == "done":
302
+ # ── plan / revise / show: read the plan BEFORE anything is created ──
303
+ verb, _, rest = message.partition(" ")
304
+ if verb.lower() in ("plan", "revise", "show"):
305
+ if verb.lower() == "show":
306
+ if not plan:
307
+ print(" no plan yet — type 'plan' to build one.")
308
+ else:
309
+ print("\n" + render_plan(plan) + "\n")
310
+ continue
311
+ missing = [k for k, v in requirements.items() if not str(v or "").strip()]
312
+ if not requirements or missing:
313
+ print(f" not yet — still needed: "
314
+ f"{', '.join(missing) or 'everything'}")
315
+ continue
316
+ if verb.lower() == "revise" and not plan:
317
+ print(" nothing to revise yet — type 'plan' first.")
318
+ continue
319
+ if verb.lower() == "revise" and not rest.strip():
320
+ print(' say what to change, e.g. revise split the first epic in two')
321
+ continue
322
+ print("\n building the plan … (nothing is created by this)")
323
+ try:
324
+ res = await build_plan(
325
+ conversation_id or "", requirements=requirements,
326
+ history=history,
327
+ plan=plan if verb.lower() == "revise" else None,
328
+ feedback=rest.strip() if verb.lower() == "revise" else None,
329
+ )
330
+ except IntakeError as exc:
331
+ print(f" ✗ {exc}")
332
+ continue
333
+ plan = res.get("plan") or plan
334
+ epics, stories = _plan_counts(plan or {})
335
+ print("\n" + render_plan(plan or {}))
336
+ print(f"\n {epics} epic(s), {stories} story(ies) — nothing created yet.")
337
+ print(" 'revise <what to change>' to adjust · 'create' to make the "
338
+ "tickets · 'quit' to abandon.\n")
339
+ continue
340
+
341
+ if message.lower() in ("done", "create"):
181
342
  missing = [k for k, v in requirements.items() if not str(v or "").strip()]
182
343
  if not requirements or missing:
183
344
  print(f" not yet — still needed: "
@@ -187,13 +348,26 @@ def chat_cli(argv: list[str]) -> None:
187
348
  print(" a product id is required to create tickets. Re-run with "
188
349
  "`agentos chat --product-id <ID>`.")
189
350
  return
190
- print("\n building the plan and creating tickets … (this takes a minute)")
191
351
  try:
192
- result = await finalize(
193
- conversation_id or "", requirements=requirements,
194
- product_id=product_id, history=history, pv_id=pv_id,
195
- autorun=autorun,
196
- )
352
+ if plan:
353
+ # A reviewed plan is committed AS REVIEWED — sent back
354
+ # rather than rebuilt, so what was on screen is what gets
355
+ # created. Rebuilding here would create something the
356
+ # reviewer never saw.
357
+ print("\n creating tickets from the reviewed plan …")
358
+ result = await create(
359
+ conversation_id or "", plan=plan, product_id=product_id,
360
+ requirements=requirements, pv_id=pv_id, autorun=autorun,
361
+ )
362
+ else:
363
+ print("\n building the plan and creating tickets … "
364
+ "(this takes a minute; type 'plan' first if you want "
365
+ "to read it before it is committed)")
366
+ result = await finalize(
367
+ conversation_id or "", requirements=requirements,
368
+ product_id=product_id, history=history, pv_id=pv_id,
369
+ autorun=autorun,
370
+ )
197
371
  except IntakeError as exc:
198
372
  print(f" ✗ {exc}")
199
373
  return