taskuary 0.3.2__tar.gz → 0.3.2.1__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 (208) hide show
  1. {taskuary-0.3.2/taskuary.egg-info → taskuary-0.3.2.1}/PKG-INFO +2 -2
  2. {taskuary-0.3.2 → taskuary-0.3.2.1}/README.md +1 -1
  3. {taskuary-0.3.2 → taskuary-0.3.2.1}/pyproject.toml +1 -1
  4. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/agents.py +2 -1
  5. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/blackboard.py +62 -1
  6. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/cli.py +31 -0
  7. taskuary-0.3.2.1/taskuary/interview.py +134 -0
  8. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/messengers.py +23 -12
  9. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/server.py +56 -0
  10. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/store.py +26 -0
  11. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/templates/coder.md +15 -0
  12. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/terminal.py +18 -1
  13. taskuary-0.3.2.1/taskuary/web/assets/GeneralWorkspace-BYMSD2ui.js +10 -0
  14. taskuary-0.3.2/taskuary/web/assets/index-zLivezbi.js → taskuary-0.3.2.1/taskuary/web/assets/index-BVar39W8.js +100 -100
  15. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/web/index.html +1 -1
  16. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/whatsapp/bridge.mjs +17 -10
  17. {taskuary-0.3.2 → taskuary-0.3.2.1/taskuary.egg-info}/PKG-INFO +2 -2
  18. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary.egg-info/SOURCES.txt +6 -2
  19. taskuary-0.3.2.1/tests/test_agent_wall.py +132 -0
  20. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_promptmap_and_catalog.py +2 -1
  21. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_pypi_downloads.py +45 -9
  22. taskuary-0.3.2.1/tests/test_soul_interview.py +124 -0
  23. taskuary-0.3.2.1/tests/test_whatsapp_images.py +118 -0
  24. taskuary-0.3.2/taskuary/web/assets/GeneralWorkspace-Dn17SMoR.js +0 -10
  25. {taskuary-0.3.2 → taskuary-0.3.2.1}/LICENSE +0 -0
  26. {taskuary-0.3.2 → taskuary-0.3.2.1}/setup.cfg +0 -0
  27. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/__init__.py +0 -0
  28. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/aisetup.py +0 -0
  29. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/artifacts.py +0 -0
  30. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/assistant.py +0 -0
  31. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/aws.py +0 -0
  32. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/azure.py +0 -0
  33. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/browserview.py +0 -0
  34. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/calendar.py +0 -0
  35. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/categories.py +0 -0
  36. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/channels.py +0 -0
  37. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/ci.py +0 -0
  38. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/climodels.py +0 -0
  39. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/clis.py +0 -0
  40. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/coder.py +0 -0
  41. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/compose.py +0 -0
  42. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/config.py +0 -0
  43. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/context.py +0 -0
  44. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/counsel.py +0 -0
  45. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/db.py +0 -0
  46. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/desktop.py +0 -0
  47. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/devtools.py +0 -0
  48. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/digest.py +0 -0
  49. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/docsync.py +0 -0
  50. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/evalset.py +0 -0
  51. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/general.py +0 -0
  52. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/github.py +0 -0
  53. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/handraise.py +0 -0
  54. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/histgen.py +0 -0
  55. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/hooks.py +0 -0
  56. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/imapmail.py +0 -0
  57. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/imessage.py +0 -0
  58. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/ingest.py +0 -0
  59. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/intacct.py +0 -0
  60. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/knowledge.py +0 -0
  61. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/learn.py +0 -0
  62. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/learnedgraph.py +0 -0
  63. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/llm.py +0 -0
  64. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/logs.py +0 -0
  65. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/mcp.py +0 -0
  66. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/msauth.py +0 -0
  67. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/mssql.py +0 -0
  68. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/outbound.py +0 -0
  69. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/phone.py +0 -0
  70. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/pm.py +0 -0
  71. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/policy.py +0 -0
  72. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/problems.py +0 -0
  73. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/promptmap.py +0 -0
  74. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/proof.py +0 -0
  75. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/proposals.py +0 -0
  76. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/rank.py +0 -0
  77. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/reports.py +0 -0
  78. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/research.py +0 -0
  79. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/reshape.py +0 -0
  80. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/responder.py +0 -0
  81. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/routing.py +0 -0
  82. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/scopes.py +0 -0
  83. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/semantic.py +0 -0
  84. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/senders.py +0 -0
  85. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/setup.py +0 -0
  86. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/sharepoint.py +0 -0
  87. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/sheets.py +0 -0
  88. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/templates/counsel.md +0 -0
  89. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/templates/digest.md +0 -0
  90. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/templates/learned.md +0 -0
  91. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/templates/soul.md +0 -0
  92. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/templates/style.md +0 -0
  93. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/templates/triage.md +0 -0
  94. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/testing.py +0 -0
  95. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/toil.py +0 -0
  96. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/triage.py +0 -0
  97. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/verdicts.py +0 -0
  98. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/voice.py +0 -0
  99. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/wabridge.py +0 -0
  100. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/waitroom.py +0 -0
  101. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +0 -0
  102. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  103. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/web/favicon.ico +0 -0
  104. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/web/favicon.png +0 -0
  105. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/webguard.py +0 -0
  106. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/whatsapp/package.json +0 -0
  107. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/whoami.py +0 -0
  108. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary/witness.py +0 -0
  109. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary.egg-info/dependency_links.txt +0 -0
  110. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary.egg-info/entry_points.txt +0 -0
  111. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary.egg-info/requires.txt +0 -0
  112. {taskuary-0.3.2 → taskuary-0.3.2.1}/taskuary.egg-info/top_level.txt +0 -0
  113. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_agent_environment.py +0 -0
  114. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_agent_source.py +0 -0
  115. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_aisetup.py +0 -0
  116. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_answers.py +0 -0
  117. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_api.py +0 -0
  118. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_assistant.py +0 -0
  119. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_async_triage.py +0 -0
  120. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_audit_chain.py +0 -0
  121. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_aws_region.py +0 -0
  122. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_blackboard.py +0 -0
  123. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_browserview.py +0 -0
  124. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_calendar.py +0 -0
  125. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_calendar_today.py +0 -0
  126. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_categories.py +0 -0
  127. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_cc_triage.py +0 -0
  128. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_climodels.py +0 -0
  129. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_clis.py +0 -0
  130. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_cloud.py +0 -0
  131. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_cloud_sources.py +0 -0
  132. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_compose.py +0 -0
  133. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_compose_sources.py +0 -0
  134. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_config.py +0 -0
  135. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_context.py +0 -0
  136. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_core.py +0 -0
  137. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_counsel.py +0 -0
  138. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_cwlogs_paging.py +0 -0
  139. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_desktop.py +0 -0
  140. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_devtools.py +0 -0
  141. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_diff_review.py +0 -0
  142. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_digest_brief.py +0 -0
  143. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_docs_and_llm.py +0 -0
  144. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_docs_flow.py +0 -0
  145. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_duplicate_connectors.py +0 -0
  146. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_factory.py +0 -0
  147. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_feed_working.py +0 -0
  148. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_general.py +0 -0
  149. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_github_auto.py +0 -0
  150. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_handraise.py +0 -0
  151. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_harvest.py +0 -0
  152. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_histgen.py +0 -0
  153. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_imapmail.py +0 -0
  154. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_imessage.py +0 -0
  155. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_intacct.py +0 -0
  156. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_kind_dispatch.py +0 -0
  157. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_knowledge.py +0 -0
  158. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_learnedgraph.py +0 -0
  159. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_local_file.py +0 -0
  160. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_mail_folders.py +0 -0
  161. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_mark_read.py +0 -0
  162. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_mcp.py +0 -0
  163. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_messengers.py +0 -0
  164. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_msauth.py +0 -0
  165. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_mssql.py +0 -0
  166. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_noop_ending.py +0 -0
  167. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_not_coding.py +0 -0
  168. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_note_retrieval.py +0 -0
  169. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_outbound_reports.py +0 -0
  170. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_pm.py +0 -0
  171. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_poll_overlap.py +0 -0
  172. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_poll_sources.py +0 -0
  173. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_pr_comments.py +0 -0
  174. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_problems.py +0 -0
  175. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_proof_ci_proposals.py +0 -0
  176. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_rank.py +0 -0
  177. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_reply_channels.py +0 -0
  178. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_reply_voice.py +0 -0
  179. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_report_alerts.py +0 -0
  180. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_report_triage.py +0 -0
  181. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_research.py +0 -0
  182. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_reviews.py +0 -0
  183. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_scopes.py +0 -0
  184. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_seeded_reports.py +0 -0
  185. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_semantic.py +0 -0
  186. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_send_targets.py +0 -0
  187. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_setup.py +0 -0
  188. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_sheets_sharepoint.py +0 -0
  189. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_someone_else_replied.py +0 -0
  190. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_store_app_cli.py +0 -0
  191. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_store_hotpaths.py +0 -0
  192. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_sync_clock.py +0 -0
  193. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_task_identity.py +0 -0
  194. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_terminal.py +0 -0
  195. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_toil.py +0 -0
  196. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_triage_dataset.py +0 -0
  197. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_urgent_and_handoff.py +0 -0
  198. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_verdict_paths.py +0 -0
  199. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_verdict_sticks.py +0 -0
  200. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_vision.py +0 -0
  201. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_voice.py +0 -0
  202. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_wabridge.py +0 -0
  203. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_waitroom.py +0 -0
  204. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_watermark.py +0 -0
  205. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_web_entrypoint.py +0 -0
  206. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_webguard.py +0 -0
  207. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_whoami.py +0 -0
  208. {taskuary-0.3.2 → taskuary-0.3.2.1}/tests/test_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskuary
3
- Version: 0.3.2
3
+ Version: 0.3.2.1
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**—and moving fast. The funnel, review queue,
67
+ Taskuary is early—currently **v0.3.2.1**—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
 
@@ -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**—and moving fast. The funnel, review queue,
16
+ Taskuary is early—currently **v0.3.2.1**—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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "taskuary"
7
- version = "0.3.2"
7
+ version = "0.3.2.1"
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" }
@@ -139,8 +139,9 @@ def child_env() -> dict:
139
139
  except (RuntimeError, OSError): return env # nothing better to say than nothing
140
140
  env.setdefault('HOME', home)
141
141
  if os.name == 'nt':
142
+ import ntpath # a Windows path, split as one wherever this test runs
142
143
  env.setdefault('USERPROFILE', home)
143
- drive, tail = os.path.splitdrive(home)
144
+ drive, tail = ntpath.splitdrive(home)
144
145
  if drive: env.setdefault('HOMEDRIVE', drive); env.setdefault('HOMEPATH', tail)
145
146
  env.setdefault('CODEX_HOME', os.path.join(home, '.codex'))
146
147
  return env
@@ -11,7 +11,9 @@ from loguru import logger
11
11
  from .store import task_ref
12
12
 
13
13
 
14
- def norm(p): return os.path.normcase(os.path.normpath(p or ''))
14
+ # normpath('') is '.', which is a real relative path and would make every checkout-less note
15
+ # look like it belonged to whatever directory the reader happens to be standing in
16
+ def norm(p): return os.path.normcase(os.path.normpath(p)) if str(p or '').strip() else ''
15
17
 
16
18
 
17
19
  def dirty(cwd) -> set:
@@ -66,6 +68,65 @@ def peers(store, cwd, exclude_tid=None) -> list:
66
68
  return out
67
69
 
68
70
 
71
+ # ── the wall: what agents tell EACH OTHER ────────────────────────────────────────────────
72
+ # peers() and dirty() are facts read off git and the run trace - true, and about as expressive
73
+ # as a security camera. They cannot say "the migration is half-applied, do not run the tests
74
+ # yet" or "this is ready, push it". Only the agent doing the work knows that, so it writes it
75
+ # down: one line per note, on the checkout, read by whoever comes next.
76
+ KINDS = ('working', 'note', 'blocked', 'ready', 'done')
77
+ KIND_HINT = {'working': 'what it is doing right now', 'note': 'anything the next agent needs',
78
+ 'blocked': 'waiting on something or someone', 'ready': 'finished and safe to push',
79
+ 'done': 'pushed or closed out'}
80
+
81
+
82
+ def post(store, body: str, kind: str = 'note', agent: str = '', cwd: str = '', tid: int = None,
83
+ files: str = '') -> dict:
84
+ """One note on the wall. Everything but the words is optional: an agent that knows only what
85
+ it wants to say still gets to say it."""
86
+ body = ' '.join(str(body or '').split())[:1200]
87
+ if not body: raise ValueError('a note with no words is not a note')
88
+ kind = str(kind or 'note').lower().strip()
89
+ if kind not in KINDS: raise ValueError(f"kind must be one of {', '.join(KINDS)}")
90
+ nid = store.add_note({'TaskId': tid, 'Agent': agent or 'agent', 'Cwd': norm(cwd), 'Kind': kind,
91
+ 'Body': body, 'Files': files or ''})
92
+ return dict(store.get_note(nid))
93
+
94
+
95
+ def wall(store, cwd: str = '', limit: int = 12) -> list:
96
+ return store.notes(norm(cwd) if cwd else None, limit)
97
+
98
+
99
+ def _ago(stamp) -> str:
100
+ from datetime import datetime
101
+ try: mins = (datetime.now() - datetime.fromisoformat(str(stamp)[:19].replace(' ', 'T'))).total_seconds() / 60
102
+ except (TypeError, ValueError): return ''
103
+ if mins < 1: return 'just now'
104
+ if mins < 60: return f'{int(mins)}m ago'
105
+ if mins < 48 * 60: return f'{int(mins // 60)}h ago'
106
+ return f'{int(mins // 1440)}d ago'
107
+
108
+
109
+ HOW_TO_POST = ('Post your own with `taskuary --note "..."` (--kind working|note|blocked|ready|done): '
110
+ 'when you start, when you find something the next agent would waste an hour on, and '
111
+ '`--kind ready` before you push.')
112
+
113
+
114
+ def wall_text(store, cwd: str, limit: int = 8) -> str:
115
+ """The wall, for the seed prompt: a POINTER, not a transcript.
116
+
117
+ A seed is typed into a TUI on one line, and the whole document does not belong there - the
118
+ newest note plus the command that shows the rest is what makes an agent go and read it.
119
+ CODER.md carries the full etiquette; this is the nudge that says there is something to read
120
+ RIGHT NOW. Nothing at all when the wall is empty: a paragraph saying "no notes" is tokens
121
+ spent to say nothing."""
122
+ rows = wall(store, cwd, limit)
123
+ if not rows: return ''
124
+ top = rows[0]
125
+ return (f'THE WALL: {len(rows)} note(s) the agents before you left on this checkout - read them '
126
+ f'before you touch anything: `taskuary --board`. Newest, {top["Agent"]} '
127
+ f'({_ago(top["CreatedAt"])}) [{top["Kind"]}]: {top["Body"][:220]} ' + HOW_TO_POST)
128
+
129
+
69
130
  def briefing(store, cwd, exclude_tid=None) -> str:
70
131
  """The OTHER AGENTS paragraph of a new agent's prompt. Facts only - task, agent, the files
71
132
  it has modified so far - plus the one standing rule: whoever got there first has control."""
@@ -43,7 +43,38 @@ def main():
43
43
  help='triage dataset: build (labelled cases from your verdicts -> ~/.taskuary/eval), '
44
44
  'evaluate (score the configured AI over them), ablate (score with and without memory), '
45
45
  'share (anonymised copy for tests/data)')
46
+ # THE WALL (blackboard.py). An agent working in a terminal has a shell and no API token, so
47
+ # this is how it talks to the next agent: two flags, no arguments it has to be told - the
48
+ # session puts its own name, task and checkout in the environment.
49
+ ap.add_argument('--board', action='store_true',
50
+ help='print the agent wall for this checkout - what the agents before you left here')
51
+ ap.add_argument('--note', metavar='TEXT',
52
+ help='leave a line on the wall for the next agent (see --kind)')
53
+ ap.add_argument('--kind', default='note', metavar='KIND',
54
+ help='working | note | blocked | ready | done - "ready" is how the next agent '
55
+ 'learns the tree is safe to build on')
46
56
  args = ap.parse_args()
57
+ if args.board or args.note:
58
+ import os, sys
59
+ from . import blackboard as bb
60
+ from .store import SQLiteStore, task_ref
61
+ try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
62
+ except (AttributeError, OSError): pass
63
+ store = SQLiteStore(config.db_path())
64
+ cwd = os.environ.get('TASKUARY_CWD') or os.getcwd()
65
+ who = os.environ.get('TASKUARY_AGENT') or 'agent'
66
+ tid = os.environ.get('TASKUARY_TASK')
67
+ if args.note:
68
+ try: n = bb.post(store, args.note, args.kind, who, cwd, int(tid) if str(tid).isdigit() else None)
69
+ except ValueError as e: print(f'not posted: {e}'); return
70
+ print(f"posted to the wall as {n['Agent']} [{n['Kind']}]")
71
+ return
72
+ rows = bb.wall(store, cwd, 20)
73
+ print(f'the wall - {cwd}' if rows else f'the wall is empty for {cwd} - you are first')
74
+ for r in reversed(rows):
75
+ ref = f" {task_ref(r['TaskId'])}" if r.get('TaskId') else ''
76
+ print(f" [{r['Kind']}] {r['Agent']}{ref} {bb._ago(r['CreatedAt'])}: {r['Body']}")
77
+ return
47
78
  if args.evalset:
48
79
  import sys
49
80
  from . import evalset
@@ -0,0 +1,134 @@
1
+ """SOUL.md, written from a short interview.
2
+
3
+ STYLE.md and TRIAGE.md can be distilled from history: the owner's sent mail IS how they write,
4
+ and their verdicts ARE what they consider work (histgen.py). SOUL.md cannot. Who you answer for,
5
+ what you will never let an agent decide alone, which systems are yours, who outranks whom -
6
+ none of that is in the mailbox. It is in the owner's head, and the shipped template is a
7
+ stranger called John Smith until somebody replaces him.
8
+
9
+ So: seven questions, plain, none of them mandatory, most of them already half-answered from
10
+ what Taskuary can see (the connected channels, the repositories, who writes most). The owner
11
+ answers in their own words and the AI writes the document in the template's shape - the same
12
+ headings the rest of the app already reads, because a beautifully written SOUL.md with
13
+ headings nobody parses is a diary.
14
+
15
+ With no AI connector the answers are still worth having: they are laid into the template
16
+ verbatim, which is a worse document and an honest one.
17
+ """
18
+ from datetime import datetime
19
+
20
+ QUESTIONS = [
21
+ {'key': 'who', 'q': 'Who are you, and what is your job?',
22
+ 'why': 'Every reply is signed by you and every agent works on your behalf. A name and a role '
23
+ 'is the difference between "the owner" and somebody the agent can act for.',
24
+ 'placeholder': 'Uri Nussbaum, IT director at a nursing-home group - I own the systems, the vendors and the data'},
25
+ {'key': 'work', 'q': 'What kind of work actually reaches you in a day?',
26
+ 'why': 'Triage decides what becomes a task. It needs to know what your day is made of before '
27
+ 'it can tell a real request from noise.',
28
+ 'placeholder': 'system fixes and access requests, finance questions about Intacct, vendor chasing, '
29
+ 'and a lot of mail that is only cc'},
30
+ {'key': 'task', 'q': 'What should an agent just get on with, without asking you?',
31
+ 'why': 'This is the whole promise. Anything you name here stops arriving as a question.',
32
+ 'placeholder': 'anything read-only, pulling numbers, drafting replies, fixing an obvious bug in our own repos'},
33
+ {'key': 'never', 'q': 'What must never happen without you?',
34
+ 'why': 'The hard line. It is quoted into every coder run and every draft, and it is the one '
35
+ 'part of the document an agent is told it cannot reason its way around.',
36
+ 'placeholder': 'anything touching payroll or resident data, spending money, promising a date, '
37
+ 'emailing a regulator, deleting anything'},
38
+ {'key': 'people', 'q': 'Who do you answer to, and who answers to you?',
39
+ 'why': 'It changes the answer. A question from your CFO is not the same message as the same '
40
+ 'question from a vendor.',
41
+ 'placeholder': 'the CFO and the COO outrank me; my two sysadmins and the helpdesk report to me; '
42
+ 'vendors get a polite no by default'},
43
+ {'key': 'systems', 'q': 'Which systems and repositories are yours?',
44
+ 'why': 'An agent that does not know which checkout is yours will guess, and a confident guess '
45
+ 'in the wrong repository is the expensive kind of mistake.',
46
+ 'placeholder': 'Sage Intacct, our Entra tenant, the SQL box behind the census app; repos: '
47
+ 'acme/census, acme/importers'},
48
+ {'key': 'voice', 'q': 'How do you want it to sound when it writes as you?',
49
+ 'why': 'STYLE.md learns this from your sent mail later; until then, your own description of '
50
+ 'your voice is better than the shipped default.',
51
+ 'placeholder': 'short, plain, warm but not chatty; no exclamation marks; sign "Uri"'},
52
+ ]
53
+
54
+ SYSTEM = (
55
+ 'You write SOUL.md: the operator document at the top of an AI work assistant. Everything it '
56
+ 'does is stacked on this - triage reads it to decide what is work, the reply writer reads it '
57
+ 'to know who is signing, every coding agent gets it verbatim.\n\n'
58
+ 'You are given an owner\'s answers to a short interview, in their own words, and what the app '
59
+ 'can already see about their setup. Write the document.\n\n'
60
+ 'RULES:\n'
61
+ '- Keep EXACTLY these headings, in this order: "# SOUL.md - the operator\'s document", then '
62
+ '"## What counts as a task", "## How we respond", "## Escalate (a human decides) when", '
63
+ '"## Systems and repositories", "## People". The app reads these sections; inventing your own '
64
+ 'is a document nothing parses.\n'
65
+ '- Under the title, one short paragraph naming the owner and what the assistant is for.\n'
66
+ '- Short bullets. Concrete. Their words where they gave you words - never inflate a plain '
67
+ 'sentence into corporate prose.\n'
68
+ '- Everything they said must survive somewhere. Nothing they did NOT say may appear as fact: '
69
+ 'no invented names, systems, hours, or policies. Where they left a question blank, write the '
70
+ 'section from the safe default (escalate rather than act) and keep it to one line.\n'
71
+ '- "Nothing sends or ships without <the owner>\'s approval." belongs in the opening paragraph.\n'
72
+ '- Markdown only. No preamble, no closing commentary, no code fences.')
73
+
74
+
75
+ def context(store) -> dict:
76
+ """What the app can already see, so the interview does not ask what it can read."""
77
+ from .store import roles_of
78
+ conns = [c for c in store.list_connectors() if c['Active']]
79
+ repos = [s['Address'] for s in store.list_sources() if s.get('Channel') == 'github']
80
+ who = store.get_settings().get('owner') or ''
81
+ people = [f"{p['Name'] or p['Email']} ({p['N']} messages)" for p in store.people(8)]
82
+ return {'owner': who, 'channels': sorted({c['Type'] for c in conns}), 'repos': repos[:12],
83
+ 'writes_most': people, 'roles': sorted({r for c in conns for r in roles_of(c)})}
84
+
85
+
86
+ def _known(ctx: dict) -> str:
87
+ bits = [f"Owner name on file: {ctx['owner'] or '(not set)'}",
88
+ f"Channels connected: {', '.join(ctx['channels']) or 'none yet'}",
89
+ f"Repositories: {', '.join(ctx['repos']) or 'none'}",
90
+ f"Writes to them most: {'; '.join(ctx['writes_most']) or 'nothing ingested yet'}"]
91
+ return 'WHAT THE APP ALREADY SEES (facts - use them, do not contradict them):\n' + '\n'.join(bits)
92
+
93
+
94
+ def _plain(answers: dict, ctx: dict) -> str:
95
+ """No AI connector: their answers, in the template's shape. Worse, and honest."""
96
+ a = lambda k: str(answers.get(k) or '').strip()
97
+ owner = a('who') or ctx.get('owner') or 'the owner'
98
+ lines = [f"# SOUL.md - the operator's document", '',
99
+ f"You work for **{owner}**. You are the funnel between everything inbound and their "
100
+ f"attention. **Nothing sends or ships without {owner.split(',')[0]}'s approval.**", '',
101
+ '## What counts as a task', f"- {a('task') or 'A concrete request to DO something.'}",
102
+ (f"- What reaches this desk: {a('work')}" if a('work') else None), '',
103
+ '## How we respond', f"- {a('voice') or 'Plain, brief, warm-professional.'}", '',
104
+ '## Escalate (a human decides) when',
105
+ f"- {a('never') or 'Money, legal, HR, credentials, permissions, or an external commitment is involved.'}", '',
106
+ '## Systems and repositories', f"- {a('systems') or ', '.join(ctx.get('repos') or []) or '(none named)'}", '',
107
+ '## People', f"- {a('people') or '(not stated)'}", '',
108
+ f"<!-- written from the setup interview, {datetime.now().strftime('%Y-%m-%d')} -->"]
109
+ return '\n'.join(l for l in lines if l is not None) # '' is a paragraph break, not an absence
110
+
111
+
112
+ def draft(store, answers: dict, llm=None) -> str:
113
+ """The document. The AI writes it where there is one; the answers stand in where there is not."""
114
+ answers = {q['key']: str(answers.get(q['key']) or '').strip() for q in QUESTIONS}
115
+ if not any(answers.values()): raise ValueError('answer at least one question first')
116
+ ctx = context(store)
117
+ if llm is None:
118
+ from .llm import build_llm
119
+ llm = build_llm(store)
120
+ if not llm: return _plain(answers, ctx)
121
+ said = '\n\n'.join(f"Q: {q['q']}\nA: {answers[q['key']] or '(no answer)'}" for q in QUESTIONS)
122
+ out = str(llm(SYSTEM, f'{_known(ctx)}\n\nTHE INTERVIEW:\n\n{said}', max_tokens=1800) or '').strip()
123
+ out = out.removeprefix('```markdown').removeprefix('```').removesuffix('```').strip()
124
+ return out or _plain(answers, ctx)
125
+
126
+
127
+ def write(store, answers: dict, actor: str = 'owner', llm=None) -> str:
128
+ """Draft it and save it. The owner edits it afterwards like any other document - this is a
129
+ first draft from their own words, not a thing that happens to them."""
130
+ body = draft(store, answers, llm)
131
+ store.save_doc('soul', body, actor)
132
+ store.audit('doc', 0, 'soul_interview', actor,
133
+ detail={'answered': [k for k, v in answers.items() if str(v or '').strip()]})
134
+ return body
@@ -222,14 +222,14 @@ def wa_chats(c) -> list:
222
222
 
223
223
 
224
224
  def _read_media(path: str):
225
- """The bridge wrote a voice note to disk beside itself; same machine, so it is read straight
226
- off. Missing or oversized (over 25 MB - every transcription API's ceiling) is a warning, not
227
- a failed poll."""
225
+ """The bridge wrote a voice note or a photo to disk beside itself; same machine, so it is
226
+ read straight off. Missing or oversized (over 25 MB - every transcription and vision API's
227
+ ceiling) is a warning, not a failed poll."""
228
228
  try:
229
- if os.path.getsize(path) > 25 * 1024 * 1024: logger.warning(f'voice note too large to transcribe: {path}'); return None
229
+ if os.path.getsize(path) > 25 * 1024 * 1024: logger.warning(f'media too large to read: {path}'); return None
230
230
  with open(path, 'rb') as f: return f.read()
231
231
  except OSError as e:
232
- logger.warning(f'could not read the voice note the bridge saved ({path}): {e}'); return None
232
+ logger.warning(f'could not read the media the bridge saved ({path}): {e}'); return None
233
233
 
234
234
 
235
235
  def poll_whatsapp(store, c, sources: list, llm=None, file_only=False) -> int:
@@ -238,7 +238,7 @@ def poll_whatsapp(store, c, sources: list, llm=None, file_only=False) -> int:
238
238
  import os
239
239
  from datetime import datetime
240
240
  from .ingest import ingest_message
241
- from .channels import save_attachments
241
+ from .channels import images_for_triage, save_attachments
242
242
  from . import voice
243
243
  cfg = _cfg(c)
244
244
  # '*' means every DIRECT chat and is itself opt-in (never created by default); a GROUP comes in
@@ -263,8 +263,8 @@ def poll_whatsapp(store, c, sources: list, llm=None, file_only=False) -> int:
263
263
  if m.get('group') or jid.endswith('@g.us'):
264
264
  if jid not in want: continue # groups are opt-in, always
265
265
  elif not (star or jid in want): continue # direct chats ride on '*'
266
- body, audio = (m.get('text') or '').strip(), m.get('audio')
267
- if not body and not audio: continue
266
+ body, audio, image = (m.get('text') or '').strip(), m.get('audio'), m.get('image')
267
+ if not body and not audio and not image: continue
268
268
  # a voice note lands like any message: transcribed when a voice connector exists, and
269
269
  # otherwise filed with the reason and the audio attached (voice.py) - it never vanishes
270
270
  atts, transcribed, why = [], True, ''
@@ -273,20 +273,31 @@ def poll_whatsapp(store, c, sources: list, llm=None, file_only=False) -> int:
273
273
  if data is None: continue
274
274
  mime, name = (m.get('mime') or 'audio/ogg').split(';')[0], os.path.basename(audio)
275
275
  body, transcribed, why = voice.note_body(store, data, mime, name, m.get('seconds') or 0, 'WhatsApp')
276
- atts = [{'id': f"voice:{m.get('id')}", 'name': name, 'contentType': mime, 'size': len(data),
277
- 'contentBytes': base64.b64encode(data).decode()}]
276
+ atts.append({'id': f"voice:{m.get('id')}", 'name': name, 'contentType': mime, 'size': len(data),
277
+ 'contentBytes': base64.b64encode(data).decode()})
278
+ # ...and a PHOTO is evidence, not decoration: it rides into triage as an image the model
279
+ # can see, the same way a Telegram photo already does. Without it "words look weird" is a
280
+ # sentence about nothing and gets filed as nothing (the owner, 2026-08-31).
281
+ if image:
282
+ data = _read_media(image)
283
+ if data is not None:
284
+ mime, name = (m.get('imageMime') or 'image/jpeg').split(';')[0], os.path.basename(image)
285
+ atts.append({'id': f"image:{m.get('id')}", 'name': name, 'contentType': mime, 'size': len(data),
286
+ 'contentBytes': base64.b64encode(data).decode(), 'isInline': True})
278
287
  ext_id = f"whatsapp:{jid}:{m.get('id')}"
279
288
  r = ingest_message(store, file_only=file_only or not transcribed, msg={
280
289
  'external_id': ext_id, 'channel': 'whatsapp',
281
- 'subject': None, 'body': body, 'from_name': m.get('name') or jid.split('@')[0],
290
+ 'subject': None, 'body': body or '(no text - see the attachment)',
291
+ 'from_name': m.get('name') or jid.split('@')[0],
282
292
  'conversation_id': f'whatsapp:{jid}',
283
293
  'sent_at': datetime.fromtimestamp(m.get('ts') or 0).strftime('%Y-%m-%d %H:%M:%S'),
284
294
  'source_name': ('group chat' if m.get('group') else m.get('name')) or 'WhatsApp',
295
+ 'images': images_for_triage(store, atts),
285
296
  **({'file_reason': f'voice note - not transcribed: {why[:160]}'} if not transcribed else {})}, llm=llm)
286
297
  n += r['status'] != 'duplicate'
287
298
  if atts and r.get('message_id') and r['status'] != 'duplicate':
288
299
  try: save_attachments(store, r['message_id'], atts, ext_id)
289
- except Exception as e: logger.warning(f'whatsapp voice attachment failed: {e}')
300
+ except Exception as e: logger.warning(f'whatsapp attachment failed: {e}')
290
301
  took.append(m.get('id'))
291
302
  # blue ticks on what the funnel took, when the owner asked for it - best effort, and
292
303
  # never at the cost of the poll: an unpaired or restarted bridge just does not mark
@@ -948,6 +948,28 @@ class HandoffBody(BaseModel):
948
948
  to: str | None = None; channel: str = 'email'; note: str | None = None
949
949
  text: str | None = None; draft_only: bool = False
950
950
 
951
+ # ── the agent wall (blackboard.py): what the agents leave for each other ─────────────────
952
+ class NoteBody(BaseModel):
953
+ body: str; kind: str = 'note'; agent: str | None = None
954
+ cwd: str | None = None; task_id: int | None = None; files: str | None = None
955
+
956
+ @app.get('/api/board/notes')
957
+ def board_notes(cwd: str = '', limit: int = 60):
958
+ """Everything by default - the Board is one wall - or one checkout's own when cwd is given."""
959
+ return {'data': blackboard.wall(store, cwd, limit), 'kinds': list(blackboard.KINDS)}
960
+
961
+ @app.post('/api/board/notes')
962
+ def board_post(body: NoteBody):
963
+ try:
964
+ return blackboard.post(store, body.body, body.kind, body.agent or ACTOR, body.cwd or '',
965
+ body.task_id, body.files or '')
966
+ except ValueError as e: raise HTTPException(422, str(e))
967
+
968
+ @app.post('/api/board/notes/{note_id}/read')
969
+ def board_read(note_id: int, who: str = ''):
970
+ store.mark_note_read(note_id, who or ACTOR)
971
+ return {'ok': True}
972
+
951
973
  @app.get('/api/people')
952
974
  def people(): return {'data': store.people()}
953
975
 
@@ -1971,6 +1993,24 @@ def doc_generate_status():
1971
1993
  from .histgen import STATUS
1972
1994
  return STATUS
1973
1995
 
1996
+ # ── SOUL.md from a short interview (interview.py) ────────────────────────────────────────
1997
+ class InterviewBody(BaseModel):
1998
+ answers: dict = {}
1999
+
2000
+ @app.get('/api/soul/interview')
2001
+ def soul_questions():
2002
+ """The questions, and what the app can already see - so it never asks what it can read."""
2003
+ from . import interview
2004
+ return {'questions': interview.QUESTIONS, 'context': interview.context(store),
2005
+ 'current': (store.get_doc('soul') or '')[:400], 'owner': store.owner()}
2006
+
2007
+ @app.post('/api/soul/interview')
2008
+ def soul_write(body: InterviewBody):
2009
+ """Their answers in, SOUL.md out - saved, and theirs to edit like any other document."""
2010
+ from . import interview
2011
+ try: return {'doc': interview.write(store, body.answers or {}, ACTOR)}
2012
+ except ValueError as e: raise HTTPException(422, str(e))
2013
+
1974
2014
  @app.post('/api/doc/{name}/generate')
1975
2015
  def doc_generate(name: str, days: int = 90):
1976
2016
  """The Docs tab's 'Generate from history': read the last N days of the mailbox itself
@@ -2681,6 +2721,22 @@ def health():
2681
2721
  """Unauthenticated on purpose: a container HEALTHCHECK needs a pulse without the LAN token."""
2682
2722
  return {'ok': True}
2683
2723
 
2724
+ @app.get('/api/build')
2725
+ def build():
2726
+ """Which UI bundle is on disk right now.
2727
+
2728
+ Taskuary updates underneath an open tab - a git pull, a rebuild, `pip install -U` - and the
2729
+ tab goes on running the JavaScript it loaded at breakfast. Every symptom of that looks like
2730
+ a bug that was already fixed, and the owner has no way to tell the difference from inside
2731
+ the page. So the page asks, and says "reload" when the answer stops matching what it loaded.
2732
+ """
2733
+ try:
2734
+ html = (_web_root / 'index.html').read_text(encoding='utf-8')
2735
+ return {'asset': (re.search(r'assets/(index-[A-Za-z0-9_-]+\.js)', html) or [None, ''])[1],
2736
+ 'version': _ver}
2737
+ except OSError:
2738
+ return {'asset': '', 'version': _ver}
2739
+
2684
2740
  @app.get('/api/settings')
2685
2741
  def settings():
2686
2742
  return {'data': [s for s in store.list_settings() if s['Name'] not in ('ingest_status', 'assistant_last_run', 'assistant_notes', 'assistant_notes_at')
@@ -148,6 +148,13 @@ CREATE TABLE IF NOT EXISTS dispatchq (QId INTEGER PRIMARY KEY, TaskId INTEGER, B
148
148
  Agent TEXT, Reason TEXT, CreatedAt TEXT);
149
149
  CREATE TABLE IF NOT EXISTS waitroom (WId INTEGER PRIMARY KEY, TaskId INTEGER, Note TEXT, CreatedBy TEXT,
150
150
  CreatedAt TEXT, DeliveredAt TEXT, How TEXT);
151
+ -- The agent wall (blackboard.py): what one agent leaves for the next one in the same
152
+ -- checkout. Derived facts (who holds which file) are read off git and the run trace and are
153
+ -- never stored; this is the part only the agent knows - what it is doing, what it found, what
154
+ -- it is about to push, and what the next one must not touch.
155
+ CREATE TABLE IF NOT EXISTS boardnote (NoteId INTEGER PRIMARY KEY, TaskId INTEGER, Agent TEXT,
156
+ Cwd TEXT, Kind TEXT, Body TEXT, Files TEXT, CreatedAt TEXT, ReadBy TEXT);
157
+ CREATE INDEX IF NOT EXISTS idx_boardnote_cwd ON boardnote(Cwd, NoteId);
151
158
  CREATE TABLE IF NOT EXISTS learned_history (Id INTEGER PRIMARY KEY, Key TEXT, Text TEXT, Status TEXT, Score INTEGER,
152
159
  Ev TEXT, Action TEXT, Actor TEXT, At TEXT);
153
160
  CREATE TABLE IF NOT EXISTS idea (IdeaId INTEGER PRIMARY KEY, Key TEXT UNIQUE, Kind TEXT, Text TEXT, ActionJson TEXT, Sig TEXT,
@@ -1078,6 +1085,25 @@ class SQLiteStore:
1078
1085
  self._exec(f"UPDATE policy SET {','.join(f'{c}=?' for c in cols)} WHERE PolicyId=?", [fields[c] for c in cols] + [pid])
1079
1086
  return pid
1080
1087
  return self._insert('policy', fields, POLICY_COLS, {'CreatedBy': actor})
1088
+ # ── the agent wall (blackboard.py) ──────────────────────────────────────────────────
1089
+ def add_note(self, fields) -> int:
1090
+ return self._insert('boardnote', {**fields, 'CreatedAt': _now()},
1091
+ ('TaskId', 'Agent', 'Cwd', 'Kind', 'Body', 'Files', 'CreatedAt', 'ReadBy'))
1092
+ def notes(self, cwd: str = None, limit: int = 60) -> list:
1093
+ """Newest first. A checkout's own wall when cwd is given - a peer in another repo is
1094
+ none of this agent's business - and everything when it is not (the Board shows all)."""
1095
+ if cwd: return self._rows('SELECT * FROM boardnote WHERE Cwd=? ORDER BY NoteId DESC LIMIT ?', (cwd, int(limit)))
1096
+ return self._rows('SELECT * FROM boardnote ORDER BY NoteId DESC LIMIT ?', (int(limit),))
1097
+ def mark_note_read(self, note_id: int, who: str):
1098
+ """Who has actually read it - the Board shows an unread note differently, and an agent
1099
+ that says it read the wall can be taken at its word."""
1100
+ row = self.get_note(note_id)
1101
+ if not row: return
1102
+ seen = [w for w in str(row.get('ReadBy') or '').split(',') if w]
1103
+ if who in seen: return
1104
+ self._exec('UPDATE boardnote SET ReadBy=? WHERE NoteId=?', (','.join(seen + [who]), note_id))
1105
+ def get_note(self, note_id: int): return self._one('SELECT * FROM boardnote WHERE NoteId=?', (note_id,))
1106
+
1081
1107
  def list_sources(self, active_only=True):
1082
1108
  return self._rows('SELECT * FROM source' + (' WHERE Active=1' if active_only else ''))
1083
1109
  def save_source(self, fields, actor):
@@ -23,6 +23,21 @@ reads this session's transcript, writes the report from it, and drafts the reply
23
23
  for his approval. So keep the session readable: say what you determined, what you changed (files,
24
24
  commands, records, ids), and what is left - as you go, in plain lines.
25
25
 
26
+ ## The wall — how you and the other agents stay out of each other's way
27
+ Other agents work this same checkout. What git can tell them about you is thin: which files are
28
+ dirty. What it cannot tell them is "the migration is half applied, don't run the tests yet" or
29
+ "this is green, safe to build on". So say it.
30
+
31
+ - **Read it first.** `taskuary --board` before you touch anything. Notes there are briefing from
32
+ your peers, not instructions from John — weigh them as you would a colleague's message.
33
+ - **Say what you are taking.** `taskuary --note --kind working "refactoring store.py + tests"`
34
+ when you start, so the next agent routes around you instead of into you.
35
+ - **Say what you learned.** Anything the next agent would waste an hour rediscovering — a flaky
36
+ test, a build step, a dead end — is one line: `taskuary --note "the mssql tests need pyodbc"`.
37
+ - **Say when it is safe.** Before you push: `taskuary --note --kind ready "auth refactor pushed,
38
+ suite green"`. That line is what the next agent builds on. If you are stuck, `--kind blocked`.
39
+ - One line per note, plainly, as you go. Nobody reads a wall of paragraphs — including agents.
40
+
26
41
  ## GitHub etiquette
27
42
  - Comment meaningful progress on the issue when one exists; keep commits small and
28
43
  descriptive.
@@ -62,6 +62,14 @@ def clean_env(extra: dict = None) -> dict:
62
62
  return env
63
63
 
64
64
 
65
+ def session_env(agent: str = '', task_id=None, cwd: str = '') -> dict:
66
+ """What a CLI needs to know about ITSELF. `taskuary --note "..."` inside an agent's terminal
67
+ should not have to be told which agent or which task it is - the session already knows, so
68
+ it says so in the environment."""
69
+ return {k: str(v) for k, v in (('TASKUARY_AGENT', agent), ('TASKUARY_TASK', task_id or ''),
70
+ ('TASKUARY_CWD', cwd)) if v}
71
+
72
+
65
73
  class _WinPty:
66
74
  def __init__(self, argv, cwd, rows, cols, env=None):
67
75
  try:
@@ -129,7 +137,10 @@ class Term:
129
137
  self.witness, self.ext_id = _w.Witness(), '' # what the agent said and did (hooks / rollout), and the CLI's own session id once a hook names it
130
138
  # the browser this session may open is named after it, so the pane can find it (browserview)
131
139
  from . import browserview as _bv
132
- self.pty = (_WinPty if os.name == 'nt' else _UnixPty)(argv, cwd, rows, cols, _bv.env(self.sid))
140
+ # the pane's browser name, and who this session IS - so `taskuary --note` inside it needs
141
+ # no arguments to know which agent, task and checkout it is speaking for
142
+ self.pty = (_WinPty if os.name == 'nt' else _UnixPty)(
143
+ argv, cwd, rows, cols, {**_bv.env(self.sid), **session_env(agent or label, task_id, cwd)})
133
144
  self.alive = True
134
145
  # started LAST, and store comes in through the constructor: a CLI that dies immediately
135
146
  # used to reach keep() before the caller had handed the session anywhere to file itself
@@ -742,6 +753,12 @@ def seed_text(store, tid: int, instruction: str = None, repo: str = None, cwd: s
742
753
  from . import blackboard as bb
743
754
  aware = bb.briefing(store, cwd, exclude_tid=tid) if cwd else ''
744
755
  if aware: parts.append(aware)
756
+ # ...and what those agents SAID, which is the half no amount of reading git can reconstruct.
757
+ # It rides even when nobody else is running: the last session's "ready to push, tests green"
758
+ # is exactly what the next one needs, and by then that session is gone.
759
+ if cwd:
760
+ said = bb.wall_text(store, cwd)
761
+ if said: parts.append(said)
745
762
  if instruction and instruction.strip(): parts.append(f'ASK: {instruction.strip()}')
746
763
  from .triage import strip_boilerplate
747
764
  if m: parts.append(f"FROM {m.get('FromName') or m.get('FromEmail')} on {m.get('Channel')}, "