taskuary 0.3.2.3__tar.gz → 0.3.2.4__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 (234) hide show
  1. {taskuary-0.3.2.3/taskuary.egg-info → taskuary-0.3.2.4}/PKG-INFO +5 -3
  2. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/README.md +4 -2
  3. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/pyproject.toml +1 -1
  4. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/aisetup.py +1 -1
  5. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/assistant.py +129 -5
  6. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/categories.py +7 -2
  7. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/channels.py +11 -3
  8. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/ci.py +1 -1
  9. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/cli.py +58 -1
  10. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/coder.py +98 -4
  11. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/compose.py +2 -2
  12. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/config.py +42 -2
  13. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/db.py +1 -1
  14. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/demo.py +1 -0
  15. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/docsync.py +1 -1
  16. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/evalset.py +1 -1
  17. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/general.py +64 -1
  18. taskuary-0.3.2.4/taskuary/guard.py +101 -0
  19. taskuary-0.3.2.4/taskuary/handbook.py +271 -0
  20. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/histgen.py +1 -1
  21. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/hooks.py +10 -1
  22. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/ingest.py +30 -8
  23. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/llm.py +1 -1
  24. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/messengers.py +12 -1
  25. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/outbound.py +7 -3
  26. taskuary-0.3.2.4/taskuary/outbox.py +164 -0
  27. taskuary-0.3.2.4/taskuary/ownwork.py +85 -0
  28. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/problems.py +2 -2
  29. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/reports.py +39 -6
  30. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/research.py +3 -3
  31. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/routing.py +8 -3
  32. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/scopes.py +10 -1
  33. taskuary-0.3.2.4/taskuary/selfclose.py +247 -0
  34. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/server.py +281 -63
  35. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/setup.py +14 -7
  36. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/store.py +173 -10
  37. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/coder.md +25 -2
  38. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/triage.md +7 -3
  39. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/terminal.py +94 -6
  40. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/triage.py +35 -14
  41. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/voice.py +2 -2
  42. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/waitroom.py +7 -2
  43. taskuary-0.3.2.4/taskuary/web/assets/GeneralWorkspace-SrMAdua9.js +12 -0
  44. taskuary-0.3.2.4/taskuary/web/assets/SocialView-BV2-O2sP.js +1 -0
  45. taskuary-0.3.2.4/taskuary/web/assets/index-CJp5GTAb.js +1013 -0
  46. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/index.html +1 -1
  47. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/whatsapp/bridge.mjs +12 -1
  48. {taskuary-0.3.2.3 → taskuary-0.3.2.4/taskuary.egg-info}/PKG-INFO +5 -3
  49. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/SOURCES.txt +23 -2
  50. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_agent_wall.py +10 -2
  51. taskuary-0.3.2.4/tests/test_answered_yourself.py +91 -0
  52. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_api.py +2 -1
  53. taskuary-0.3.2.4/tests/test_assistant_verdicts.py +85 -0
  54. taskuary-0.3.2.4/tests/test_chat_doors.py +152 -0
  55. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_compose.py +1 -1
  56. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_compose_sources.py +1 -1
  57. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_config.py +7 -1
  58. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_digest_brief.py +13 -4
  59. taskuary-0.3.2.4/tests/test_finished_chat_stays_finished.py +85 -0
  60. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_general.py +54 -0
  61. taskuary-0.3.2.4/tests/test_guard.py +104 -0
  62. taskuary-0.3.2.4/tests/test_handbook.py +120 -0
  63. taskuary-0.3.2.4/tests/test_handbook_topics.py +76 -0
  64. taskuary-0.3.2.4/tests/test_home_is_never_yours.py +70 -0
  65. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_kind_dispatch.py +23 -6
  66. taskuary-0.3.2.4/tests/test_meeting_prep.py +75 -0
  67. taskuary-0.3.2.4/tests/test_message_thread.py +93 -0
  68. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_noop_ending.py +12 -0
  69. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_not_coding.py +26 -6
  70. taskuary-0.3.2.4/tests/test_own_rows_no_reply.py +40 -0
  71. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_problems.py +1 -1
  72. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_reply_channels.py +3 -1
  73. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_research.py +1 -1
  74. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_reviews.py +12 -0
  75. taskuary-0.3.2.4/tests/test_seed_fits_a_tty.py +56 -0
  76. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_setup.py +29 -6
  77. taskuary-0.3.2.4/tests/test_stay_open.py +100 -0
  78. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_terminal.py +68 -5
  79. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_waitroom.py +11 -0
  80. taskuary-0.3.2.4/tests/test_whatsapp_documents.py +89 -0
  81. taskuary-0.3.2.4/tests/test_withdrawn.py +70 -0
  82. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_witness.py +2 -1
  83. taskuary-0.3.2.3/taskuary/web/assets/GeneralWorkspace-HLQ4Jry6.js +0 -10
  84. taskuary-0.3.2.3/taskuary/web/assets/index-BjUFr2Di.js +0 -941
  85. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/LICENSE +0 -0
  86. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/setup.cfg +0 -0
  87. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/__init__.py +0 -0
  88. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/agents.py +0 -0
  89. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/artifacts.py +0 -0
  90. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/aws.py +0 -0
  91. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/azure.py +0 -0
  92. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/blackboard.py +0 -0
  93. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/browserview.py +0 -0
  94. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/calendar.py +0 -0
  95. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/climodels.py +0 -0
  96. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/clis.py +0 -0
  97. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/context.py +0 -0
  98. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/counsel.py +0 -0
  99. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/desktop.py +0 -0
  100. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/devtools.py +0 -0
  101. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/digest.py +0 -0
  102. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/github.py +0 -0
  103. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/handraise.py +0 -0
  104. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/imapmail.py +0 -0
  105. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/imessage.py +0 -0
  106. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/intacct.py +0 -0
  107. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/interview.py +0 -0
  108. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/knowledge.py +0 -0
  109. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/learn.py +0 -0
  110. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/learnedgraph.py +0 -0
  111. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/logs.py +0 -0
  112. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/mcp.py +0 -0
  113. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/msauth.py +0 -0
  114. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/mssql.py +0 -0
  115. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/phone.py +0 -0
  116. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/pm.py +0 -0
  117. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/policy.py +0 -0
  118. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/promptmap.py +0 -0
  119. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/proof.py +0 -0
  120. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/proposals.py +0 -0
  121. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/rank.py +0 -0
  122. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/reshape.py +0 -0
  123. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/responder.py +0 -0
  124. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/semantic.py +0 -0
  125. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/senders.py +0 -0
  126. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/sharepoint.py +0 -0
  127. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/sheets.py +0 -0
  128. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/counsel.md +0 -0
  129. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/digest.md +0 -0
  130. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/learned.md +0 -0
  131. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/soul.md +0 -0
  132. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/style.md +0 -0
  133. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/testing.py +0 -0
  134. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/toil.py +0 -0
  135. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/verdicts.py +0 -0
  136. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/wabridge.py +0 -0
  137. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +0 -0
  138. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  139. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/favicon.ico +0 -0
  140. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/favicon.png +0 -0
  141. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/webguard.py +0 -0
  142. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/whatsapp/package.json +0 -0
  143. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/whoami.py +0 -0
  144. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/witness.py +0 -0
  145. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/dependency_links.txt +0 -0
  146. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/entry_points.txt +0 -0
  147. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/requires.txt +0 -0
  148. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/top_level.txt +0 -0
  149. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_agent_environment.py +0 -0
  150. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_agent_source.py +0 -0
  151. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_aisetup.py +0 -0
  152. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_answers.py +0 -0
  153. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_assistant.py +0 -0
  154. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_async_triage.py +0 -0
  155. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_audit_chain.py +0 -0
  156. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_aws_region.py +0 -0
  157. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_blackboard.py +0 -0
  158. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_browser_session.py +0 -0
  159. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_browserview.py +0 -0
  160. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_calendar.py +0 -0
  161. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_calendar_today.py +0 -0
  162. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_categories.py +0 -0
  163. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cc_triage.py +0 -0
  164. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_chat_queue.py +0 -0
  165. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_climodels.py +0 -0
  166. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_clis.py +0 -0
  167. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cloud.py +0 -0
  168. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cloud_sources.py +0 -0
  169. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_context.py +0 -0
  170. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_core.py +0 -0
  171. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_counsel.py +0 -0
  172. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cwlogs_paging.py +0 -0
  173. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_demo.py +0 -0
  174. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_desktop.py +0 -0
  175. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_devtools.py +0 -0
  176. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_diff_review.py +0 -0
  177. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_docs_and_llm.py +0 -0
  178. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_docs_flow.py +0 -0
  179. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_duplicate_connectors.py +0 -0
  180. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_factory.py +0 -0
  181. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_feed_working.py +0 -0
  182. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_github_auto.py +0 -0
  183. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_handraise.py +0 -0
  184. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_harvest.py +0 -0
  185. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_histgen.py +0 -0
  186. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_imapmail.py +0 -0
  187. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_imessage.py +0 -0
  188. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_intacct.py +0 -0
  189. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_knowledge.py +0 -0
  190. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_learnedgraph.py +0 -0
  191. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_local_file.py +0 -0
  192. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mail_folders.py +0 -0
  193. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mark_read.py +0 -0
  194. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mcp.py +0 -0
  195. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_messengers.py +0 -0
  196. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_msauth.py +0 -0
  197. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mssql.py +0 -0
  198. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_note_retrieval.py +0 -0
  199. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_outbound_reports.py +0 -0
  200. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_pm.py +0 -0
  201. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_poll_overlap.py +0 -0
  202. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_poll_sources.py +0 -0
  203. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_pr_comments.py +0 -0
  204. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_promptmap_and_catalog.py +0 -0
  205. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_proof_ci_proposals.py +0 -0
  206. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_pypi_downloads.py +0 -0
  207. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_rank.py +0 -0
  208. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_reply_voice.py +0 -0
  209. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_report_alerts.py +0 -0
  210. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_report_triage.py +0 -0
  211. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_scopes.py +0 -0
  212. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_seeded_reports.py +0 -0
  213. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_semantic.py +0 -0
  214. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_send_targets.py +0 -0
  215. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_sheets_sharepoint.py +0 -0
  216. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_someone_else_replied.py +0 -0
  217. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_soul_interview.py +0 -0
  218. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_store_app_cli.py +0 -0
  219. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_store_hotpaths.py +0 -0
  220. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_sync_clock.py +0 -0
  221. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_task_identity.py +0 -0
  222. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_toil.py +0 -0
  223. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_triage_dataset.py +0 -0
  224. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_urgent_and_handoff.py +0 -0
  225. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_verdict_paths.py +0 -0
  226. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_verdict_sticks.py +0 -0
  227. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_vision.py +0 -0
  228. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_voice.py +0 -0
  229. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_wabridge.py +0 -0
  230. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_watermark.py +0 -0
  231. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_web_entrypoint.py +0 -0
  232. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_webguard.py +0 -0
  233. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_whatsapp_images.py +0 -0
  234. {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_whoami.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskuary
3
- Version: 0.3.2.3
3
+ Version: 0.3.2.4
4
4
  Summary: Automate your job - the local-first operating system for your work: AI triage in, coding agents out.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/ldbumble/taskuary
@@ -64,7 +64,7 @@ and replies wait for your approval. Nothing sends or ships without you.
64
64
 
65
65
  ![The Taskuary Studio: work arrives, triage decides what needs action, and agents take seats to work it.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/hero.gif)
66
66
 
67
- Taskuary is early—currently **v0.3.2.3**—and moving fast. The funnel, review queue,
67
+ Taskuary is early—currently **v0.3.2.4**—and moving fast. The funnel, review queue,
68
68
  agent sessions, and reports pipeline are in daily use; breaking changes are still possible
69
69
  before 1.0.
70
70
 
@@ -158,7 +158,9 @@ taskuary --demo # or: docker compose --profile demo up
158
158
 
159
159
  A full Taskuary over invented work — a morning's mail and chats already triaged with the
160
160
  reasons attached, drafts waiting in Review, agents on the board with their transcripts
161
- playing, the wall they leave each other notes on. The AI answers from a script and every door
161
+ playing, live task handoffs on the Board, and the durable agent-written handbook in Social.
162
+ Every Social entry names the agent and originating task; Board handoffs are short-lived
163
+ checkout coordination that rolls up nightly. The AI answers from a script and every door
162
164
  to the outside world is shut at the API layer (`taskuary/demo.py`): nothing sends, no
163
165
  connection can be made or edited, no tool runs, and no CLI starts. Nobody's real data is in
164
166
  it — Dana Whitfield and Northwind Facilities are invented.
@@ -13,7 +13,7 @@ and replies wait for your approval. Nothing sends or ships without you.
13
13
 
14
14
  ![The Taskuary Studio: work arrives, triage decides what needs action, and agents take seats to work it.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/hero.gif)
15
15
 
16
- Taskuary is early—currently **v0.3.2.3**—and moving fast. The funnel, review queue,
16
+ Taskuary is early—currently **v0.3.2.4**—and moving fast. The funnel, review queue,
17
17
  agent sessions, and reports pipeline are in daily use; breaking changes are still possible
18
18
  before 1.0.
19
19
 
@@ -107,7 +107,9 @@ taskuary --demo # or: docker compose --profile demo up
107
107
 
108
108
  A full Taskuary over invented work — a morning's mail and chats already triaged with the
109
109
  reasons attached, drafts waiting in Review, agents on the board with their transcripts
110
- playing, the wall they leave each other notes on. The AI answers from a script and every door
110
+ playing, live task handoffs on the Board, and the durable agent-written handbook in Social.
111
+ Every Social entry names the agent and originating task; Board handoffs are short-lived
112
+ checkout coordination that rolls up nightly. The AI answers from a script and every door
111
113
  to the outside world is shut at the API layer (`taskuary/demo.py`): nothing sends, no
112
114
  connection can be made or edited, no tool runs, and no CLI starts. Nobody's real data is in
113
115
  it — Dana Whitfield and Northwind Facilities are invented.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "taskuary"
7
- version = "0.3.2.3"
7
+ version = "0.3.2.4"
8
8
  description = "Automate your job - the local-first operating system for your work: AI triage in, coding agents out."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -87,7 +87,7 @@ def start(store, server: dict, cid: int, guide: list, fields: list = None, secre
87
87
  if live: return {**live, 'existing': True}
88
88
  from . import agents as hub_agents
89
89
  agent = (agent or hub_agents.default_agent(store)).strip()
90
- if not store.get_agent(agent): raise ValueError(f'no CLI agent named {agent!r} - add one under Connectors > AI CLI agents first')
90
+ if not store.get_agent(agent): raise ValueError(f'no CLI agent named {agent!r} - add one under Connections > AI CLI agents first')
91
91
  tid = store.create_task({'Title': f"Set up {c.get('Name') or c.get('Type')}", 'Kind': KIND, 'Status': 'in_progress', 'Tags': tag(cid),
92
92
  'Summary': f"{agent} walks the owner through the {c.get('Type')} guide in a live session on the card and saves what they give it."}, actor)
93
93
  # no repo: the session sits in Taskuary's own data folder, where there is no checkout to attribute dirt to
@@ -301,6 +301,10 @@ CONTRACT = ('\n\nYou are writing your POST on the owner\'s Timeline - the short
301
301
  'CALENDAR, what arrived (with each report\'s schedule and each failure\'s cause), what got done, what is open, and WHAT '
302
302
  'YOU ALREADY SAID. Answer JSON only: {"say": [{"key": "<a candidate key, or idea:<short-slug> for a thought of your own>", '
303
303
  '"text": "<one line, under 30 words, first person: the fact and what I would do - quote the phrase or name the cause when there is one>", '
304
+ '"section": "<people|loose|ideas|systems - which part of the post this belongs under: people = what somebody said '
305
+ 'or asked, loose = something waiting on somebody (a chase, a promise, work gone quiet), systems = a threshold, a '
306
+ 'failure or a number out of a connected system, ideas = your own thought. A candidate the hub found has a section '
307
+ 'already and yours is ignored for those; this is for idea:* lines>", '
304
308
  '"why": "<one line: what this rests on - the mail, the date, the silence, the pattern - named as it appears in what you '
305
309
  'were given (sender, subject, mid, TQ-ref), so the owner can check it>", "mid": <the message id it is '
306
310
  'about, or null>, "task": "<idea:* only - a task title the owner could accept as-is, or null>"}], '
@@ -495,6 +499,9 @@ def parse(text: str, cands: list, max_lines: int = MAX_LINES) -> list:
495
499
  mid = s.get('mid') if isinstance(s.get('mid'), int) else None
496
500
  title = _short(s.get('task'), 120) or None
497
501
  act = {'type': 'task', 'mid': mid, 'title': title} if title and mid else {'type': 'message', 'mid': mid} if mid else {'type': 'note'}
502
+ # where in the post it goes. Only an idea gets to choose: a candidate the hub found is
503
+ # placed by the producer that found it, and no model answer overrides that.
504
+ act['section'] = section_of({'section': s.get('section'), 'kind': 'idea'})
498
505
  out.append({'key': key[:120], 'kind': 'idea', 'sig': txt[:60], 'text': txt, 'action': act,
499
506
  'why': why or 'the model gave no reason - treat it as a hunch' + (f' (about mid {mid})' if mid else '')})
500
507
  else: continue
@@ -575,6 +582,43 @@ def system_checks(store, source_ids=None, inline=None) -> str:
575
582
  return '\n\n'.join(blocks)
576
583
 
577
584
 
585
+ def _verdicts_block(store, cands: list, source: str = "") -> str:
586
+ """The owner's standing verdicts - the SAME memory triage reads before it classifies
587
+ (ingest.relevant_notes / applicable_notes).
588
+
589
+ The assistant was the one brain in this app that never saw them. So "resident refunds are not
590
+ our problem" stayed true for triage - which quietly files them - and was news to the brief,
591
+ which went on raising the next refund thread as an idea worth acting on. A verdict the owner
592
+ gives once should not have to be given again per surface.
593
+
594
+ Scoped by the words of the material this check is READING, so a note about a topic nothing
595
+ today touches costs nothing; global notes always apply.
596
+
597
+ `source` is what the model was handed - the threads and the sender/subject lines - and it
598
+ matters more here than the candidates do. A subject-scoped verdict is matched (ingest.topic_hit)
599
+ against the words it was learned from, "resident refund request approved"; a candidate's
600
+ `facts` is the MODEL'S PARAPHRASE of a thread, and "Barnes and Watson stall the same way"
601
+ carries not one of those four words. So the ruling missed and the brief went on raising a
602
+ subject the owner had closed. Match the source it summarised, not the summary."""
603
+ from .ingest import relevant_notes
604
+ text = ' '.join([source or '', *(str(c.get('facts') or c.get('text') or '') for c in cands)])[:8000]
605
+ if not text.strip():
606
+ return ''
607
+ try:
608
+ emails = sorted({(r.get('FromEmail') or '').lower()
609
+ for r in store.recent_messages(_since(2), limit=500) if r.get('FromEmail')})
610
+ notes_, left = relevant_notes(store, emails, text)
611
+ except Exception as e:
612
+ logger.debug(f'assistant: standing verdicts skipped - {e}')
613
+ return ''
614
+ if not notes_:
615
+ return ''
616
+ more = f' ({left} more matched and were left out)' if left else ''
617
+ return ('\n\nWHAT THE OWNER HAS ALREADY DECIDED - their standing verdicts, the same ones triage '
618
+ f'reads before it files anything{more}. Never raise something they have ruled out, and '
619
+ 'never argue with one:\n' + '\n'.join(f'- {n}' for n in notes_))
620
+
621
+
578
622
  def inputs(store, cands: list, head: str = 'CANDIDATES', watch_source_ids=None, watch_sources=None) -> str:
579
623
  """Everything one check reads, as the model sees it - the same text is the Reports tab's Preview
580
624
  (facts) and the run record (reports.run_report_source), so what it was given is never a guess."""
@@ -582,15 +626,20 @@ def inputs(store, cands: list, head: str = 'CANDIDATES', watch_source_ids=None,
582
626
  away = ooo(store)
583
627
  from . import knowledge
584
628
  facts_text = ' '.join(str(c.get('facts') or '') for c in cands)[:4000]
629
+ # built once: the model reads them, and so does the verdict matcher, which needs the real
630
+ # subjects rather than the model's words about them
631
+ said, recent = _people(store), _recent(store)
585
632
  return (f"NOW: {now.strftime('%A %d %B %Y %H:%M')}\n\n{head}:\n" + ('\n'.join(f"[{c['key']}] {c['facts']}" for c in cands) or '(none)')
586
633
  + knowledge.block(store, facts_text)
587
634
  + f"\n\nCONFIGURED SYSTEM CHECKS (pulled live for this check; failures are also worth noticing):\n{system_checks(store, watch_source_ids, watch_sources)}"
588
- + f"\n\nWHAT PEOPLE SAID (the last two days, by thread, newest first; the last lines of each, oldest first):\n{_people(store)}"
635
+ + f"\n\nWHAT PEOPLE SAID (the last two days, by thread, newest first; the last lines of each, oldest first):\n{said}"
589
636
  + '\n\nOUT OF OFFICE (from their auto-replies):\n' + ('\n'.join(f'- {k}: {v}' for k, v in away.items()) or '(nobody)')
590
637
  + f"\n\nCALENDAR (the next two days):\n{_calendar(store)}"
591
- + f"\n\nARRIVED IN THE LAST TWO DAYS (xN = that many alike; each line carries the latest message's words, a report's schedule, and a failure's cause):\n{_recent(store)}"
638
+ + f"\n\nARRIVED IN THE LAST TWO DAYS (xN = that many alike; each line carries the latest message's words, a report's schedule, and a failure's cause):\n{recent}"
592
639
  + f"\n\nDONE THIS WEEK (my own work, with the agent's summary):\n{_week(store)}"
593
- + f"\n\nOPEN WORK:\n{_open(store)}\n\nALREADY SAID (never repeat):\n{_said(store)}\n\n{_notes_block(store)}")
640
+ + f"\n\nOPEN WORK:\n{_open(store)}\n\nALREADY SAID (never repeat):\n{_said(store)}"
641
+ + _verdicts_block(store, cands, f'{said}\n{recent}')
642
+ + f"\n\n{_notes_block(store)}")
594
643
 
595
644
 
596
645
  def think(store, cands: list, llm, instruction: str = None, max_lines: int = MAX_LINES) -> list:
@@ -633,10 +682,81 @@ def _notes(text: str) -> str:
633
682
 
634
683
 
635
684
  # ── the post ─────────────────────────────────────────────────────────────────────────────────
685
+ # ── the post's shape ────────────────────────────────────────────────────────────────────
686
+ # A flat list of lines is what the post used to be, and the owner (2026-09-01): "it should
687
+ # summarize into sections... summary of what the info emails said to you, then open tasks and
688
+ # what they are working on, then things you forgot to follow up from last week, then some stats".
689
+ # The lines themselves stay exactly as they were - each one still carries its own buttons and its
690
+ # own state, which is the whole value of them - they are just SORTED into sections that say what
691
+ # kind of thing you are looking at.
692
+ #
693
+ # Two of the sections are not the model's work at all. What is in flight and what the day counted
694
+ # are FACTS the hub already holds, and asking a model to restate them is how a brief starts
695
+ # inventing a task that is not there (the digest did exactly that: TQ-0032 "pending review" when
696
+ # nothing was pending). So they are computed here and the model never sees them as its own output.
697
+ SECTIONS = (
698
+ ('people', '📥', 'What people said', 'the ask in a thread, the thing somebody told you, the answer nobody picked up'),
699
+ ('flight', '🚀', 'In flight', 'what an agent has, what closed, what has gone quiet'),
700
+ ('loose', '🧵', 'Loose ends', 'what you are waiting on, what you promised, what went cold'),
701
+ ('ideas', '💡', 'Worth a thought', 'the connection nobody made, the thing to do now so the next ask never comes'),
702
+ ('systems', '🛠️', 'From the systems', 'a threshold crossed, a job failing the same way twice, a number that moved'),
703
+ )
704
+ SECTION_KEYS = tuple(s[0] for s in SECTIONS)
705
+ # where a candidate lands when the model does not say (and it never says for hub-found ones)
706
+ KIND_SECTION = {'followup': 'loose', 'promise': 'loose', 'cold': 'loose', 'prep': 'people', 'idea': 'ideas'}
707
+
708
+
709
+ def section_of(line: dict) -> str:
710
+ """Which section one line belongs in. The model's own choice wins for its ideas; a candidate
711
+ the hub found is placed by its kind, which is the thing that produced it."""
712
+ s = str(line.get('section') or '').strip().lower()
713
+ if s in SECTION_KEYS: return s
714
+ return KIND_SECTION.get(str(line.get('kind') or ''), 'ideas')
715
+
716
+
717
+ def in_flight(store) -> list:
718
+ """What is being worked, straight off the tasks - never the model's recollection of it.
719
+ [{ref, tid, title, state}] newest activity first, the ones an agent has at the top."""
720
+ from . import terminal as term
721
+ live = {}
722
+ try: live = {t['taskId']: (t.get('agent') or t.get('label') or 'an agent') for t in term.live_sessions(tail=0) if t.get('taskId')}
723
+ except Exception: pass
724
+ out = []
725
+ for t in store.list_tasks(active_only=True):
726
+ if t.get('Status') not in ('open', 'in_progress', 'waiting'): continue
727
+ tid = t['TaskId']
728
+ agent = live.get(tid) or (t.get('RunAgent') if t.get('RunStatus') == 'running' else None)
729
+ last = _dt(store.task_last_activity(tid) or t.get('UpdatedAt') or t.get('CreatedAt'))
730
+ quiet_h = int((datetime.now() - last).total_seconds() // 3600) if last else None
731
+ state = (f'{agent} has it' if agent
732
+ else 'a reply is drafted and waiting on you' if t.get('ReviewStatus') == 'pending'
733
+ else f'quiet for {quiet_h}h' if quiet_h and quiet_h >= 24
734
+ else 'nobody is on it')
735
+ out.append({'tid': tid, 'ref': task_ref(tid), 'title': _short(t.get('Title'), 90),
736
+ 'kind': t.get('Kind') or '', 'agent': agent or '', 'state': state,
737
+ 'hot': bool(agent) or t.get('ReviewStatus') == 'pending'})
738
+ out.sort(key=lambda r: (not r['hot'],))
739
+ return out[:8]
740
+
741
+
742
+ def day_stats(store) -> list:
743
+ """The few numbers that mean something, counted - not asked for. [{n, label}]."""
744
+ from .categories import category_of, team_domains_of
745
+ team = team_domains_of(store.get_settings())
746
+ rows = store.feed(400, 1)
747
+ cats = [category_of(r, team) for r in rows]
748
+ tasks = [t for t in store.list_tasks(active_only=True) if t.get('Status') in ('open', 'in_progress', 'waiting')]
749
+ return [{'n': len(rows), 'label': 'arrived today'},
750
+ {'n': sum(1 for c in cats if c in ('info', 'automated', 'promo', 'feed', 'report')), 'label': 'wanted nothing'},
751
+ {'n': len(tasks), 'label': 'open'},
752
+ {'n': sum(1 for t in tasks if t.get('ReviewStatus') == 'pending'), 'label': 'waiting on you', 'hot': True}]
753
+
754
+
636
755
  def _public(i: dict) -> dict:
637
756
  try: a = json.loads(i.get('ActionJson') or '{}')
638
757
  except ValueError: a = {}
639
- return {'id': i['IdeaId'], 'key': i['Key'], 'kind': i['Kind'], 'text': i['Text'], 'why': a.pop('why', ''), 'action': a, 'status': i.get('Status')}
758
+ return {'id': i['IdeaId'], 'key': i['Key'], 'kind': i['Kind'], 'text': i['Text'], 'why': a.pop('why', ''), 'action': a, 'status': i.get('Status'),
759
+ 'section': section_of({'section': a.get('section'), 'kind': i['Kind']})}
640
760
 
641
761
 
642
762
  def talk(store, idea_id: int, text: str, actor: str = 'owner', llm=None) -> dict:
@@ -791,7 +911,11 @@ def _run(store, llm, instruction) -> dict:
791
911
  'BodyText': body, 'Status': 'feed'})
792
912
  store.add_route(mid, None, 'feed', None, "the assistant's post: what it noticed and what it would do - open it to talk back or act",
793
913
  [], 'assistant')
794
- store.set_brief(mid, json.dumps({'ideas': [_public(i) for i in rows], 'reviewed': rv}))
914
+ # the two blocks the model does not write. What is in flight and what the day counted are facts
915
+ # the hub already holds; asking a model to restate them is how a brief starts describing work
916
+ # that is not there. Snapshotted onto the post so it still reads correctly tomorrow.
917
+ store.set_brief(mid, json.dumps({'ideas': [_public(i) for i in rows], 'reviewed': rv,
918
+ 'flight': in_flight(store), 'stats': day_stats(store)}))
795
919
  store.set_ideas_message([i['IdeaId'] for i in rows], mid)
796
920
  store.audit('message', mid, 'assistant_post', 'assistant', 'agent', {'ideas': len(rows)})
797
921
  logger.info(f'assistant: posted {len(rows)} idea(s) as message {mid}')
@@ -8,8 +8,9 @@ means the same thing in the feed, the digest and the task page:
8
8
  review - a reply is drafted for you to send info - a PERSON told you something; nothing to do
9
9
  automated - a system told you something promo - marketing / newsletter; safe to skim past
10
10
  filed - kept, no verdict beyond "not work" ignored - a policy or you said no
11
- report / feed / yours / triaging - not verdicts: a scheduled report, a feed-only
12
- connection, your own reply, or still deciding
11
+ report / feed / yours / mine / triaging - not verdicts: a scheduled report, a feed-only
12
+ connection, your own reply, a note you left
13
+ yourself, or still deciding
13
14
 
14
15
  The info / automated / promo split is the point (owner, 2026-08-27): "info from a team member
15
16
  is more important than an FYI from a vendor's marketing team". Both are 'fyi' to the
@@ -55,6 +56,10 @@ def category_of(r: dict, team_domains=()) -> str:
55
56
  st, reason = r.get('MsgStatus') or r.get('Status') or '', (r.get('RouteReason') or '').lower()
56
57
  if r.get('Channel') == 'report': return 'report'
57
58
  if r.get('Channel') == 'assistant': return 'assistant' # the assistant's own post (assistant.py)
59
+ # a row nothing sent you: a note you left yourself, or a task you started from the Board
60
+ # (ownwork.py). It is the one kind of row that is yours rather than something that happened
61
+ # to you, and it must never wear a verdict - nothing judged it, because nothing arrived.
62
+ if r.get('Channel') == 'own': return 'mine' if (r.get('TaskKind') or '') == 'note' else 'todo'
58
63
  if r.get('Direction') == 'out' or 'your reply' in reason or 'your sent reply' in reason: return 'yours'
59
64
  if st == 'feed': return 'feed'
60
65
  if st == 'triaging': return 'triaging'
@@ -1,4 +1,4 @@
1
- """Channel connectors - the cards on the Connectors tab: Outlook mail + Microsoft Teams
1
+ """Channel connectors - the cards on the Connections tab: Outlook mail + Microsoft Teams
2
2
  (Graph, app-only client credentials) and GitHub (fine-grained PAT). test_connector is a
3
3
  live probe (token/chat-read/repo-discovery); poll_channels is the scheduled ingest that
4
4
  funnels mail and chats through the same triage as everything else. Credentials left blank
@@ -568,7 +568,15 @@ def ingest_teams_chats(store, upn: str, tok: str, since, llm=None, file_only=Fal
568
568
  for m in reversed(_teams_delta(tok, upn, since_iso)): # oldest first, so threads read in order
569
569
  user = (m.get('from') or {}).get('user') or {}
570
570
  body = _clean((m.get('body') or {}).get('content'))
571
- if m.get('messageType') != 'message' or m.get('deletedDateTime') or not user.get('id') or not body: continue
571
+ # Deleted at the source. Teams' delta reports it (mail's plain $top list does not, so
572
+ # the mailbox side of this needs a delta migration before it can say the same). If we
573
+ # already carry the row, say so on it rather than leaving a message on the Timeline that
574
+ # no longer exists in the chat - and never remove it: work may hang off it.
575
+ if m.get('deletedDateTime'):
576
+ cid_d = m.get('chatId') or ''
577
+ if cid_d: store.withdraw_message(f'teams:{cid_d}:{m["id"]}')
578
+ continue
579
+ if m.get('messageType') != 'message' or not user.get('id') or not body: continue
572
580
  cid = m.get('chatId') or ''
573
581
  topic, kind = _chat_meta(tok, cid, chats) if cid else ('', 'chat')
574
582
  name, addr = _graph_user(tok, user['id'], users)
@@ -655,7 +663,7 @@ def gh_modes(src: dict, file_only: bool) -> tuple:
655
663
  # GitHub's own author_association. Everyone else's items still become tasks (in 'tasks' mode)
656
664
  # and wait for the owner to promote them. Default off: a public repo would otherwise start an
657
665
  # agent per drive-by PR (the session cap still holds, but a queue full of strangers is not
658
- # what the cap is for). The Connectors card warns before switching a PUBLIC repo on.
666
+ # what the cap is for). The Connections card warns before switching a PUBLIC repo on.
659
667
  GH_TEAM = ('OWNER', 'MEMBER', 'COLLABORATOR')
660
668
  GH_AUTO = {'off': (), 'team': GH_TEAM, 'contributors': GH_TEAM + ('CONTRIBUTOR',), 'anyone': None}
661
669
 
@@ -24,7 +24,7 @@ TOKEN_URL = re.compile(r'https://[^@/]*@') # never let a PAT reach a log lin
24
24
 
25
25
  def _conn(store):
26
26
  c = store.get_connector_by_type('github', with_secret=True)
27
- if not (c and c.get('Secret')): raise RuntimeError('no GitHub PAT saved (Connectors → GitHub)')
27
+ if not (c and c.get('Secret')): raise RuntimeError('no GitHub PAT saved (Connections → GitHub)')
28
28
  return c
29
29
 
30
30
 
@@ -60,6 +60,27 @@ def main():
60
60
  ap.add_argument('--kind', default='note', metavar='KIND',
61
61
  help='working | note | blocked | ready | done - "ready" is how the next agent '
62
62
  'learns the tree is safe to build on')
63
+ # ...and how a session ENDS itself. The Done button was the only way a finished task ever
64
+ # produced its report and its reply, so an agent that finished at 2am and a person who never
65
+ # opened the tab left the sender with nothing (selfclose.py).
66
+ # THE HANDBOOK (handbook.py). The wall is what the next hour needs; this is what next month
67
+ # needs - and it is the difference between a company whose know-how lives in its people and
68
+ # one a new agent can be plugged into.
69
+ ap.add_argument('--learned', metavar='TEXT',
70
+ help='write one line into the company handbook: something still true next month '
71
+ '(a trap, how a system actually works, who owns what). NOT what you did - that '
72
+ 'is the task. See --topic and --body.')
73
+ ap.add_argument('--topic', default='', metavar='TOPIC',
74
+ help='with --learned: which shelf it goes on - a repository, a system, a part of '
75
+ 'the business. Defaults to the checkout you are in.')
76
+ ap.add_argument('--body', default='', metavar='TEXT',
77
+ help='with --learned: the two or three sentences under the title - the fact, why it '
78
+ 'is so, and what to do about it')
79
+ ap.add_argument('--done', metavar='SUMMARY', nargs='?', const='',
80
+ help='finish the task this session is working: close it, file the report from '
81
+ 'this transcript, and draft the reply the sender gets (you are not '
82
+ 'sending it - the owner approves it). Give one sentence on what you did '
83
+ 'or found. Do not run this while waiting on the owner.')
63
84
  args = ap.parse_args()
64
85
  if args.demo:
65
86
  import os, tempfile
@@ -68,7 +89,36 @@ def main():
68
89
  # a throwaway home, so a demo can never be pointed at somebody's real database by accident
69
90
  os.environ.setdefault('TASKUARY_HOME', tempfile.mkdtemp(prefix='taskuary-demo-'))
70
91
  print(f'demo mode: invented data in {os.environ["TASKUARY_HOME"]}, nothing can reach a real system')
71
- if args.board or args.note:
92
+ # --done goes over HTTP, unlike --note. A note is a database row and any process can write
93
+ # one; ENDING a task needs the live session's scrollback, which exists only inside the
94
+ # running server - this process would find no transcript and wrap an empty one.
95
+ if args.done is not None:
96
+ import os, sys, requests
97
+ try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
98
+ except (AttributeError, OSError): pass
99
+ tid = os.environ.get('TASKUARY_TASK')
100
+ if not str(tid).isdigit():
101
+ print('not in a Taskuary session - TASKUARY_TASK is not set, so there is no task to finish')
102
+ return
103
+ srv = config.load()['server']
104
+ host = '127.0.0.1' if srv.get('host') in ('0.0.0.0', '::', '', None) else srv['host']
105
+ base = f"http://{host}:{srv.get('port') or 7787}"
106
+ hdr = {'X-Taskuary-Token': srv['token']} if srv.get('token') else {}
107
+ try:
108
+ r = requests.post(f'{base}/api/agent/done', timeout=120, headers=hdr,
109
+ json={'task_id': int(tid), 'summary': args.done,
110
+ 'agent': os.environ.get('TASKUARY_AGENT') or 'agent'})
111
+ out = r.json() if r.headers.get('content-type', '').startswith('application/json') else {}
112
+ except Exception as e:
113
+ print(f'could not reach Taskuary at {base}: {e}'); return
114
+ if out.get('closed'):
115
+ print('task closed. Report filed from this session.'
116
+ + (' A reply to the sender is drafted and waiting on the owner.' if out.get('drafting')
117
+ else ' No reply was needed.'))
118
+ else:
119
+ print(f"not closed: {out.get('why') or out.get('detail') or r.text[:200]}")
120
+ return
121
+ if args.board or args.note or args.learned:
72
122
  import os, sys
73
123
  from . import blackboard as bb
74
124
  from .store import SQLiteStore, task_ref
@@ -78,6 +128,13 @@ def main():
78
128
  cwd = os.environ.get('TASKUARY_CWD') or os.getcwd()
79
129
  who = os.environ.get('TASKUARY_AGENT') or 'agent'
80
130
  tid = os.environ.get('TASKUARY_TASK')
131
+ if args.learned:
132
+ from . import handbook
133
+ try: p = handbook.post(store, args.learned, args.body, args.topic, args.kind if args.kind in handbook.KINDS else 'howto',
134
+ who, int(tid) if str(tid).isdigit() else None, cwd)
135
+ except ValueError as e: print(f'not filed: {e}'); return
136
+ print(f"filed in the handbook under {p['Topic']}: {p['Title']}")
137
+ return
81
138
  if args.note:
82
139
  try: n = bb.post(store, args.note, args.kind, who, cwd, int(tid) if str(tid).isdigit() else None)
83
140
  except ValueError as e: print(f'not posted: {e}'); return
@@ -14,8 +14,11 @@ TRANSCRIPT_SYSTEM = (
14
14
  'the transcript ALONE - never a step it did not take, never a claim it did not make.\n'
15
15
  'Output ONLY this JSON: {"determination": "...", "actions": "...", "summary": "...", '
16
16
  '"outcome": "did_work|nothing_to_do"} - '
17
- 'determination is what was decided and why, actions is what was actually changed (files, '
18
- 'commands, records, ids), summary is the two-sentence version for someone who read none of it.\n'
17
+ 'determination is what was decided and why, in plain language and at most 80 words. '
18
+ 'actions is only what was actually changed or produced (files, commands, records, ids), '
19
+ 'at most 80 words; do not repeat the determination. summary is the concrete outcome for '
20
+ 'someone who read none of the transcript: one or two natural sentences, at most 55 words, '
21
+ 'with no headings, process narration, or repetition of the other fields.\n'
19
22
  'outcome is nothing_to_do ONLY when the session changed, produced and chased nothing because there '
20
23
  'was nothing here to do at all - the message turned out to be a notice, a reminder, a newsletter or '
21
24
  "somebody else's job. Anything the session did, found out or settled for the owner is did_work, and "
@@ -122,17 +125,108 @@ def finish(store, task_id: int, rep: dict, run_id: int = None, actor: str = 'cod
122
125
  # off, finished work just closes: the report lands on the task, no dead-end draft.
123
126
  if mid:
124
127
  from .outbound import can_reply
125
- if not can_reply(store, (store.get_message(mid) or {}).get('Channel')): mid = None
128
+ m = store.get_message(mid) or {}
129
+ if not can_reply(store, m.get('Channel')):
130
+ # a report cannot be replied to (nobody sent it), but its card may name somewhere its
131
+ # FINDINGS should go - the one configured exception to "work off a report lands on the
132
+ # timeline and nowhere else" (reports.findings_target)
133
+ from .reports import findings_target
134
+ tgt = findings_target(store, m)
135
+ if tgt:
136
+ deliver_findings(store, task_id, mid, run_id, rep, tgt)
137
+ store.update_task(task_id, {'Status': 'waiting'}, actor)
138
+ return {'drafting': True, 'message_id': mid}
139
+ mid = None
126
140
  # a held draft is proof somebody IS waiting on an answer, so it is never quietly dropped here
127
141
  if mid and not held and nobody_waiting(store, mid, rep):
128
142
  store.add_comment(task_id, actor, 'agent', 'Nothing needed doing here and the sender is not waiting on an '
129
143
  'answer - filed with the report, no reply drafted.')
130
144
  mid = None
131
- if mid: raise_reply(store, task_id, mid, run_id, rep)
145
+ # The terminal and report are already closed at this point. Publish that truth BEFORE the
146
+ # reply-writing AI call: it can take seconds (or fail), and during that time the task used to
147
+ # remain `in_progress` with no live agent. A pending review is already durable, so `waiting`
148
+ # is honest even while its draft text is being filled in.
132
149
  store.update_task(task_id, {'Status': 'waiting' if mid else 'done'}, actor)
150
+ if mid: raise_reply(store, task_id, mid, run_id, rep)
133
151
  return {'drafting': bool(mid), 'message_id': mid}
134
152
 
135
153
 
154
+ def deliver_findings(store, task_id: int, mid: int, run_id: int, rep: dict, tgt: dict) -> None:
155
+ """A report's task finished and its card names somewhere the findings should go. Same shape as
156
+ every other outgoing message: a pending review carrying its destination, which the owner reads
157
+ and approves. Nothing about "a report started this" makes the sending automatic."""
158
+ from . import outbox
159
+ rid = store.add_review({'TaskId': task_id, 'MessageId': mid, 'RunId': run_id, 'Kind': 'draft_reply',
160
+ 'Status': 'pending', 'Deliver': json.dumps(tgt),
161
+ 'Reason': f"the report's findings, for {tgt['to']} - approve to send"})
162
+ try:
163
+ draft = outbox.draft_message(store, tgt['channel'], tgt['to'],
164
+ f"what we found looking into the \"{tgt['subject']}\" report", resolution_text(rep))
165
+ store.update_review_draft(rid, draft, run_id)
166
+ except Exception as e:
167
+ logger.warning(f'findings draft failed for task {task_id}: {e}')
168
+
169
+
170
+ def wrap(store, tid: int, close: bool = True, actor: str = 'owner', sid: str = None) -> dict:
171
+ """"We're done" - the whole ending, in one callable. The transcript becomes the report, the
172
+ session dies, proposals become reviews, and finish() drafts the reply the sender gets.
173
+
174
+ It lived inside the HTTP handler, which meant the ONLY way a task could be closed out was a
175
+ person clicking Done in the browser. An agent that has finished knows it has finished long
176
+ before anyone looks at the screen (selfclose.py), so the ending had to become something other
177
+ than a route. Raises ValueError for the cases a caller must be told about; the route maps
178
+ those to 422.
179
+
180
+ Wrapping up belongs to the TASK, not to a pty: keyed on a live session it quietly vanished
181
+ ten minutes after the CLI exited and was reaped, leaving a task that could never be closed."""
182
+ from . import aisetup, general, proposals, terminal as term
183
+ task = store.get_task(tid) if tid else None
184
+ if not task: raise ValueError('this session is not on a task')
185
+ # a setup session kept no transcript on purpose (secrets were typed into it) and has no report to write
186
+ if task.get('Kind') == aisetup.KIND: return aisetup.finish(store, tid, actor)
187
+ # General work already has a durable, turn-by-turn record in task comments. It does not need a
188
+ # coding-transcript summarizer or a synthetic CODER REPORT; close the shared session and the
189
+ # task, leaving that conversation intact.
190
+ session = general.session_for(tid)
191
+ if general.handles(task) and session:
192
+ term.close(session.sid)
193
+ if close and task.get('Status') not in ('done', 'dropped'):
194
+ store.update_task(tid, {'Status': 'done'}, actor)
195
+ store.add_comment(tid, actor, 'human', 'Closed the general-work session.' + (' Marked the task done.' if close else ''))
196
+ store.audit('terminal', tid, 'wrap', actor, detail={'sid': sid or session.sid, 'close': close, 'mode': 'assistant'})
197
+ last = next((m['content'][0]['text'] for m in reversed(general.history(store, tid)) if m['role'] == 'assistant'), '')
198
+ return {'wrap': 'done', 'taskId': tid, 'report': last, 'proposed': [], 'drafting': False}
199
+ text, agent, found = term.transcript_for(store, tid)
200
+ if not text.strip(): raise ValueError('nothing to wrap up - this task has no session transcript')
201
+ if found: term.close(found) # done means done - the pty and its shells go too
202
+ rep = report_from_transcript(store, tid, text, agent)
203
+ report = resolution_text(rep)
204
+ store.add_comment(tid, actor, 'human', 'Closed the session - wrapped up from what was on screen.')
205
+ store.add_comment(tid, agent, 'agent', f'CODER REPORT\n{report}')
206
+ # anything the agent PROPOSED becomes a pending review here, at the one moment its whole
207
+ # transcript is in hand - and refusals are recorded rather than dropped (proposals.py)
208
+ proposed = []
209
+ if store.get_settings().get('proposals_enabled', '1') == '1':
210
+ try: proposed = proposals.collect(store, tid, text, agent)
211
+ except Exception as e: logger.warning(f'proposal collection failed for task {tid}: {e}')
212
+ # ...and the one question worth asking the transcript that is NOT about this task: did the
213
+ # session work out anything still true next month? Usually not, and "not" is the answer it
214
+ # is told to give (handbook.py). A failure here never stops a task closing.
215
+ from . import handbook
216
+ if handbook.enabled(store):
217
+ try: handbook.learn_from_session(store, tid, text, agent, repo=term.repo_tag(task) or '')
218
+ except Exception as e: logger.debug(f'handbook: nothing filed for task {tid} - {e}')
219
+ # 'drafting' must be what finish() ACTUALLY did, not a second guess at it: recomputing it from
220
+ # reply_target alone skipped the can-this-channel-even-reply rule, so a GitHub task with
221
+ # replies off closed with no draft while the card still promised one in Review.
222
+ fin = {}
223
+ if close and (store.get_task(tid) or {}).get('Status') not in ('done', 'dropped'):
224
+ fin = finish(store, tid, rep, None, agent) or {}
225
+ store.audit('terminal', tid, 'wrap', actor, detail={'sid': sid or found, 'close': close})
226
+ return {'wrap': 'done', 'taskId': tid, 'report': report, 'proposed': proposed,
227
+ 'drafting': bool(fin.get('drafting'))}
228
+
229
+
136
230
  def raise_reply(store, task_id: int, mid: int, run_id: int, rep: dict) -> None:
137
231
  """The session reported; the responder writes what the sender actually reads. One voice for
138
232
  every reply the owner sends - and no coding CLI drafting prose from inside a repo. A draft
@@ -211,7 +211,7 @@ def compose(store, ask: str, llm, answers: dict = None, rounds: int = MAX_PEEKS)
211
211
 
212
212
  `answers` are the owner's replies to a previous round's questions, so asking is a
213
213
  conversation rather than a dead end."""
214
- if not llm: return {'error': 'no AI connector is configured - Connectors → AI'}
214
+ if not llm: return {'error': 'no AI connector is configured - Connections → AI'}
215
215
  if not (ask or '').strip(): return {'error': 'say what you want the report to do'}
216
216
  cat = catalog(store)
217
217
  user = {'request': ask.strip(), 'catalog': cat,
@@ -235,7 +235,7 @@ def compose_sources(store, ask: str, llm, one_type: str = None, answers: dict =
235
235
 
236
236
  `one_type` is the card the owner is standing on: exactly one source comes back, of that type
237
237
  unless the ask plainly needs another system - and then `explain` says which and why."""
238
- if not llm: return {'error': 'no AI connector is configured - Connectors → AI'}
238
+ if not llm: return {'error': 'no AI connector is configured - Connections → AI'}
239
239
  if not (ask or '').strip(): return {'error': 'say what it should read'}
240
240
  cat = catalog(store)
241
241
  if one_type:
@@ -12,11 +12,43 @@ try: import tomllib
12
12
  except ImportError: import tomli as tomllib # py3.10
13
13
  from pathlib import Path
14
14
 
15
+ _TEST_MARKS = ('pytest', 'unittest', 'fastapi.testclient', 'starlette.testclient')
16
+
17
+
18
+ def _under_test() -> bool:
19
+ """Are we inside a test run? sys.modules is the honest signal: pytest and unittest are both
20
+ imported by the time anything of ours is."""
21
+ import sys
22
+ # fastapi's TestClient is the other one, and it is the one that got past this guard the very
23
+ # day it was written: a throwaway `python -c` driving the API to check a fix imports neither
24
+ # pytest nor unittest, and wrote a message, a task, two routes and a memory note into the
25
+ # owner's database. TestClient exists for nothing but testing, so its presence is proof.
26
+ return any(m in sys.modules for m in _TEST_MARKS) and not os.getenv('TASKUARY_ALLOW_TEST_HOME')
27
+
28
+
15
29
  def home() -> Path:
16
- p = Path(os.getenv('TASKUARY_HOME') or Path.home() / '.taskuary')
30
+ """Where the owner's data lives - unless a test is asking, in which case it never is.
31
+
32
+ tests/conftest.py points TASKUARY_HOME at a temp dir before anything of ours is imported, and
33
+ that works for `pytest`. It does NOT work for `python tests/test_terminal.py`, because every
34
+ test file here ends in unittest.main() and running one directly loads no conftest at all. That
35
+ door was open, and something went through it: two copies of test_terminal's fixture task, and
36
+ its graph:E2E message, in the owner's live database (2026-09-01; and the same class of
37
+ accident on 2026-08-27, which cost SOUL.md and left 140 fixture tasks on the board).
38
+
39
+ So the guard moves to where the decision is actually made. A test that has not been given a
40
+ home gets a temp one and is told so - loudly, once - instead of quietly opening the real
41
+ database. Set TASKUARY_ALLOW_TEST_HOME=1 for the rare test that means it."""
42
+ env = os.getenv('TASKUARY_HOME')
43
+ if not env and _under_test():
44
+ import tempfile
45
+ env = os.environ['TASKUARY_HOME'] = tempfile.mkdtemp(prefix='taskuary_test_')
46
+ print(f'taskuary: test run with no TASKUARY_HOME - using {env}, not your real data',
47
+ file=__import__('sys').stderr)
48
+ p = Path(env or Path.home() / '.taskuary')
17
49
  old = Path.home() / '.taskhub'
18
50
  # one-time migration from the pre-rename data dir
19
- if not os.getenv('TASKUARY_HOME') and not p.exists() and old.exists(): old.rename(p)
51
+ if not env and not p.exists() and old.exists(): old.rename(p)
20
52
  p.mkdir(parents=True, exist_ok=True)
21
53
  return p
22
54
 
@@ -24,6 +56,9 @@ def _read() -> dict:
24
56
  f = home() / 'config.toml'
25
57
  return tomllib.loads(f.read_text(encoding='utf-8')) if f.exists() else {}
26
58
 
59
+ def _write(d: dict):
60
+ (home() / 'config.toml').write_text(dumps_toml(d) + '\n', encoding='utf-8')
61
+
27
62
  def _env_server() -> dict:
28
63
  """Non-empty TASKUARY_* overlays. Empty is unset — an injected '' must not disable a stored token."""
29
64
  out = {}
@@ -38,6 +73,11 @@ def load() -> dict:
38
73
  cfg.setdefault('server', {})
39
74
  cfg['server'].setdefault('host', '127.0.0.1')
40
75
  cfg['server'].setdefault('port', 7787)
76
+ # every install gets an AGENT token, whether or not the owner set an owner token: without one
77
+ # there is no way to tell a session's request from a person's, and guard.DENIED has nothing
78
+ # to act on. It is written back to config.toml so it survives a restart.
79
+ from . import guard
80
+ guard.ensure_tokens(_read, _write, cfg['server'])
41
81
  # --dangerously-skip-permissions matters: without it a headless claude waits forever
42
82
  # for permission approvals nobody can click. stream-json (+ required --verbose) makes
43
83
  # claude emit events AS IT WORKS so the Board can stream the run live.