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.
- {taskuary-0.3.2.3/taskuary.egg-info → taskuary-0.3.2.4}/PKG-INFO +5 -3
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/README.md +4 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/pyproject.toml +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/aisetup.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/assistant.py +129 -5
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/categories.py +7 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/channels.py +11 -3
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/ci.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/cli.py +58 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/coder.py +98 -4
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/compose.py +2 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/config.py +42 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/db.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/demo.py +1 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/docsync.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/evalset.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/general.py +64 -1
- taskuary-0.3.2.4/taskuary/guard.py +101 -0
- taskuary-0.3.2.4/taskuary/handbook.py +271 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/histgen.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/hooks.py +10 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/ingest.py +30 -8
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/llm.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/messengers.py +12 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/outbound.py +7 -3
- taskuary-0.3.2.4/taskuary/outbox.py +164 -0
- taskuary-0.3.2.4/taskuary/ownwork.py +85 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/problems.py +2 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/reports.py +39 -6
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/research.py +3 -3
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/routing.py +8 -3
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/scopes.py +10 -1
- taskuary-0.3.2.4/taskuary/selfclose.py +247 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/server.py +281 -63
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/setup.py +14 -7
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/store.py +173 -10
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/coder.md +25 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/triage.md +7 -3
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/terminal.py +94 -6
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/triage.py +35 -14
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/voice.py +2 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/waitroom.py +7 -2
- taskuary-0.3.2.4/taskuary/web/assets/GeneralWorkspace-SrMAdua9.js +12 -0
- taskuary-0.3.2.4/taskuary/web/assets/SocialView-BV2-O2sP.js +1 -0
- taskuary-0.3.2.4/taskuary/web/assets/index-CJp5GTAb.js +1013 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/index.html +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/whatsapp/bridge.mjs +12 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4/taskuary.egg-info}/PKG-INFO +5 -3
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/SOURCES.txt +23 -2
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_agent_wall.py +10 -2
- taskuary-0.3.2.4/tests/test_answered_yourself.py +91 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_api.py +2 -1
- taskuary-0.3.2.4/tests/test_assistant_verdicts.py +85 -0
- taskuary-0.3.2.4/tests/test_chat_doors.py +152 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_compose.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_compose_sources.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_config.py +7 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_digest_brief.py +13 -4
- taskuary-0.3.2.4/tests/test_finished_chat_stays_finished.py +85 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_general.py +54 -0
- taskuary-0.3.2.4/tests/test_guard.py +104 -0
- taskuary-0.3.2.4/tests/test_handbook.py +120 -0
- taskuary-0.3.2.4/tests/test_handbook_topics.py +76 -0
- taskuary-0.3.2.4/tests/test_home_is_never_yours.py +70 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_kind_dispatch.py +23 -6
- taskuary-0.3.2.4/tests/test_meeting_prep.py +75 -0
- taskuary-0.3.2.4/tests/test_message_thread.py +93 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_noop_ending.py +12 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_not_coding.py +26 -6
- taskuary-0.3.2.4/tests/test_own_rows_no_reply.py +40 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_problems.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_reply_channels.py +3 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_research.py +1 -1
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_reviews.py +12 -0
- taskuary-0.3.2.4/tests/test_seed_fits_a_tty.py +56 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_setup.py +29 -6
- taskuary-0.3.2.4/tests/test_stay_open.py +100 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_terminal.py +68 -5
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_waitroom.py +11 -0
- taskuary-0.3.2.4/tests/test_whatsapp_documents.py +89 -0
- taskuary-0.3.2.4/tests/test_withdrawn.py +70 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_witness.py +2 -1
- taskuary-0.3.2.3/taskuary/web/assets/GeneralWorkspace-HLQ4Jry6.js +0 -10
- taskuary-0.3.2.3/taskuary/web/assets/index-BjUFr2Di.js +0 -941
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/LICENSE +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/setup.cfg +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/__init__.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/agents.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/artifacts.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/aws.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/azure.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/blackboard.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/browserview.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/calendar.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/climodels.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/clis.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/context.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/counsel.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/desktop.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/devtools.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/digest.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/github.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/handraise.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/imapmail.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/imessage.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/intacct.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/interview.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/knowledge.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/learn.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/learnedgraph.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/logs.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/mcp.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/msauth.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/mssql.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/phone.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/pm.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/policy.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/promptmap.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/proof.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/proposals.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/rank.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/reshape.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/responder.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/semantic.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/senders.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/sharepoint.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/sheets.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/counsel.md +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/digest.md +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/learned.md +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/soul.md +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/templates/style.md +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/testing.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/toil.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/verdicts.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/wabridge.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/favicon.ico +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/web/favicon.png +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/webguard.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/whatsapp/package.json +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/whoami.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary/witness.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/dependency_links.txt +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/entry_points.txt +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/requires.txt +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/taskuary.egg-info/top_level.txt +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_agent_environment.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_agent_source.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_aisetup.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_answers.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_assistant.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_async_triage.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_audit_chain.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_aws_region.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_blackboard.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_browser_session.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_browserview.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_calendar.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_calendar_today.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_categories.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cc_triage.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_chat_queue.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_climodels.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_clis.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cloud.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cloud_sources.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_context.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_core.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_counsel.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_cwlogs_paging.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_demo.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_desktop.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_devtools.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_diff_review.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_docs_and_llm.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_docs_flow.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_duplicate_connectors.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_factory.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_feed_working.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_github_auto.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_handraise.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_harvest.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_histgen.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_imapmail.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_imessage.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_intacct.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_knowledge.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_learnedgraph.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_local_file.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mail_folders.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mark_read.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mcp.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_messengers.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_msauth.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_mssql.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_note_retrieval.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_outbound_reports.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_pm.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_poll_overlap.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_poll_sources.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_pr_comments.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_promptmap_and_catalog.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_proof_ci_proposals.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_pypi_downloads.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_rank.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_reply_voice.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_report_alerts.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_report_triage.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_scopes.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_seeded_reports.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_semantic.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_send_targets.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_sheets_sharepoint.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_someone_else_replied.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_soul_interview.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_store_app_cli.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_store_hotpaths.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_sync_clock.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_task_identity.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_toil.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_triage_dataset.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_urgent_and_handoff.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_verdict_paths.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_verdict_sticks.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_vision.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_voice.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_wabridge.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_watermark.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_web_entrypoint.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_webguard.py +0 -0
- {taskuary-0.3.2.3 → taskuary-0.3.2.4}/tests/test_whatsapp_images.py +0 -0
- {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
|
+
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
|

|
|
66
66
|
|
|
67
|
-
Taskuary is early—currently **v0.3.2.
|
|
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,
|
|
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
|

|
|
15
15
|
|
|
16
|
-
Taskuary is early—currently **v0.3.2.
|
|
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,
|
|
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.
|
|
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
|
|
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{
|
|
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{
|
|
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)}
|
|
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
|
-
|
|
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
|
|
12
|
-
connection, your own reply,
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
18
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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 -
|
|
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
|
-
|
|
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
|
|
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.
|