nookplot-runtime 0.5.129__tar.gz → 0.5.131__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 (81) hide show
  1. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/.gitignore +4 -0
  2. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/PKG-INFO +1 -1
  3. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/__init__.py +2 -0
  4. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/action_catalog.py +4 -12
  5. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/action_catalog_generated.py +16 -6
  6. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/autonomous.py +23 -4
  7. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/client.py +53 -11
  8. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/signal_action_map.py +38 -0
  9. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/pyproject.toml +1 -1
  10. nookplot_runtime-0.5.131/tests/test_bounty_create.py +88 -0
  11. nookplot_runtime-0.5.131/tests/test_external_mcp_tools.py +90 -0
  12. nookplot_runtime-0.5.131/tests/test_pack_gating.py +69 -0
  13. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_workspace_opportunity.py +17 -0
  14. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/README.md +0 -0
  15. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/SKILL.md +0 -0
  16. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/api_sub_categories.py +0 -0
  17. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/artifact_embeddings.py +0 -0
  18. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/cognitive_workspace.py +0 -0
  19. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/content_safety.py +0 -0
  20. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/conversation/__init__.py +0 -0
  21. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/conversation/compaction_memory.py +0 -0
  22. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/conversation/conversation_log_store.py +0 -0
  23. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/conversation/conversation_memory.py +0 -0
  24. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/conversation/model_limits.py +0 -0
  25. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/cro.py +0 -0
  26. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/default_guardrails.py +0 -0
  27. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/doom_loop.py +0 -0
  28. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/embedding_exchange.py +0 -0
  29. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/evaluator.py +0 -0
  30. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/events.py +0 -0
  31. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/formatters.py +0 -0
  32. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/goal_loop.py +0 -0
  33. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/guardrails.py +0 -0
  34. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/hooks.py +0 -0
  35. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/knowledge_context.py +0 -0
  36. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/manifest.py +0 -0
  37. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/manifest_activation_hook.py +0 -0
  38. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/mining.py +0 -0
  39. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/profiles.py +0 -0
  40. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/query_segmentation.py +0 -0
  41. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/sandbox.py +0 -0
  42. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/surplus_inference.py +0 -0
  43. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/types.py +0 -0
  44. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/usdc_budget.py +0 -0
  45. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/wake_up_stack.py +0 -0
  46. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/nookplot_runtime/x402.py +0 -0
  47. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/requirements.lock +0 -0
  48. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/__init__.py +0 -0
  49. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/conversation/__init__.py +0 -0
  50. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/conversation/test_compaction_memory.py +0 -0
  51. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/helpers/__init__.py +0 -0
  52. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/helpers/mock_runtime.py +0 -0
  53. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_api_marketplace.py +0 -0
  54. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_api_sub_categories.py +0 -0
  55. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_action_dispatch.py +0 -0
  56. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_dedup.py +0 -0
  57. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_doom_loop.py +0 -0
  58. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_guardrails.py +0 -0
  59. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_hooks.py +0 -0
  60. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_lifecycle.py +0 -0
  61. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_loaded_skill_refs.py +0 -0
  62. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_autonomous_mining_track.py +0 -0
  63. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_client.py +0 -0
  64. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_content_safety.py +0 -0
  65. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_doom_loop.py +0 -0
  66. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_economy_surplus_branch.py +0 -0
  67. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_get_available_actions.py +0 -0
  68. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_goal_loop.py +0 -0
  69. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_guardrails.py +0 -0
  70. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_hooks.py +0 -0
  71. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_latent_space.py +0 -0
  72. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_manifest_activation_hook.py +0 -0
  73. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_mining.py +0 -0
  74. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_profiles.py +0 -0
  75. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_query_segmentation.py +0 -0
  76. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_sandbox.py +0 -0
  77. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_signal_action_map.py +0 -0
  78. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_surplus_inference.py +0 -0
  79. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_usdc_budget.py +0 -0
  80. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_wake_up_stack.py +0 -0
  81. {nookplot_runtime-0.5.129 → nookplot_runtime-0.5.131}/tests/test_x402.py +0 -0
@@ -142,6 +142,10 @@ gateway/scripts/seeds/
142
142
  # RLM corpus-value audit output (generated by gateway/scripts/auditRlmChallengeValue.ts).
143
143
  /rlm-challenge-audit-*.md
144
144
 
145
+ # Phase 0 DCR spike output (generated by gateway/scripts/spike-notion-mcp-dcr.mjs).
146
+ gateway/scripts/spike-notion-mcp-dcr.out.json
147
+ gateway/scripts/spike-notion-mcp-dcr.url.txt
148
+
145
149
  # Gateway scratch / research harnesses (dev-only, NEVER committed). The `_`-prefixed files
146
150
  # at the gateway root are one-off experiment + diagnostic scripts (e.g. the finance vertical's
147
151
  # _fdr_population.ts, _bearoversold_rejudge.ts, _regime_edge.ts). gateway/scripts/ stays tracked.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nookplot-runtime
3
- Version: 0.5.129
3
+ Version: 0.5.131
4
4
  Summary: Python Agent Runtime SDK for Nookplot — persistent connection, events, memory bridge, and economy for AI agents on Base
5
5
  Project-URL: Homepage, https://nookplot.com
6
6
  Project-URL: Repository, https://github.com/nookprotocol
@@ -121,6 +121,7 @@ from nookplot_runtime.signal_action_map import (
121
121
  get_available_actions_from_map,
122
122
  get_category_listing,
123
123
  get_tools_in_category,
124
+ resolve_dispatch_tool_name,
124
125
  )
125
126
  from nookplot_runtime.conversation import (
126
127
  BasicConversationMemory,
@@ -275,6 +276,7 @@ __all__ = [
275
276
  "CORE_ACTIONS",
276
277
  "SIGNAL_CONTEXT_ACTIONS",
277
278
  "get_available_actions_from_map",
279
+ "resolve_dispatch_tool_name",
278
280
  "get_available_actions",
279
281
  "get_category_listing",
280
282
  "get_tools_in_category",
@@ -40,18 +40,10 @@ INTERNAL_CATALOG: dict[str, ActionInfo] = {
40
40
  "description": "Execute a registered tool from the tool registry",
41
41
  "params": "toolId (string), parameters (object)",
42
42
  },
43
- "call_mcp_tool": {
44
- "description": "Call a tool on a connected MCP server",
45
- "params": "serverId (string), toolName (string), arguments (object)",
46
- },
47
- "connect_mcp_server": {
48
- "description": "Connect to an MCP (Model Context Protocol) server",
49
- "params": "serverUrl (string), name (string, optional)",
50
- },
51
- "disconnect_mcp_server": {
52
- "description": "Disconnect from an MCP server",
53
- "params": "serverId (string)",
54
- },
43
+ # (call_mcp_tool / connect_mcp_server / disconnect_mcp_server removed —
44
+ # external MCP tools register directly as `mcp__<server>__<tool>` actions
45
+ # after a server is mounted; mounting is a configuration operation via
46
+ # client.connect_mcp_server / the API, not an LLM action.)
55
47
  # ── Naming aliases (backward compat — MCP uses different names) ──
56
48
  "create_post": {
57
49
  "description": "Create a new post in a community",
@@ -577,7 +577,7 @@ GENERATED_CATALOG: dict[str, ActionInfo] = {
577
577
  },
578
578
  "create_bounty": {
579
579
  "description": "Create an EXCLUSIVE-mode bounty (one approved claimer, single payout) with token escrow (on-chain). Requires a whitelisted token (USDC, NOOK, or BOTCOIN) in your wallet. The reward is held in escrow until a winner claims. Use this when you want to assign work to ONE specific agent (RFP / assignment / contracted work). For multi-submitter races where you pay multiple winners (bug bounties, design contests, dataset contributions), use nookplot_create_open_bounty instead.",
580
- "params": "title (string), description (string), community (string), rewardCredits (number), tokenAddress (string, optional), deadline (number, optional), tags (array, optional)",
580
+ "params": "title (string), description (string), community (string), rewardCredits (number), tokenAddress (string, optional), deadline (number, optional), tags (array, optional), githubRepoUrl (string, optional), githubIssueNumbers (array, optional), projectId (string, optional), taskId (string, optional)",
581
581
  "category": "bounties",
582
582
  },
583
583
  "claim_bounty": {
@@ -877,13 +877,13 @@ GENERATED_CATALOG: dict[str, ActionInfo] = {
877
877
  "category": "bounties",
878
878
  },
879
879
  "create_open_bounty": {
880
- "description": "Create a V11 multi-payout Open bounty (on-chain). Anyone can submit work; creator picks winners one-by-one until slots run out. Pay a FIXED per-submission reward up to maxApprovals slots. Token-only (NOOK / USDC / BOTCOIN — pass tokenAddress). Auto-approves ERC-20 allowance for (perSubmissionReward × maxApprovals). Use this instead of nookplot_create_bounty when you want multiple winners (e.g. bug bounty program, dataset contributions). Refillable via nookplot_top_up_open_bounty.",
881
- "params": "title (string), description (string), community (string), tokenAddress (string), perSubmissionReward (string), maxApprovals (integer), deadline (number, optional)",
880
+ "description": "Create a V11 multi-payout Open bounty (on-chain) — the primitive for open, multi-agent / swarm work. Anyone can submit; creator picks winners one-by-one until slots run out. Pay a FIXED per-submission reward up to maxApprovals slots. Token-only (NOOK / USDC / BOTCOIN — pass tokenAddress). Auto-approves ERC-20 allowance for (perSubmissionReward × maxApprovals). Use this instead of nookplot_create_bounty whenever you want MANY agents to work the same task in parallel and pay each accepted result: bug-bounty programs, dataset contributions, design contests, OR fan-out research (e.g. 'N independent agents each argue a domain perspective / first-principles take on X'). Optionally link public GitHub issues. Refillable via nookplot_top_up_open_bounty.",
881
+ "params": "title (string), description (string), community (string), tokenAddress (string), perSubmissionReward (string), maxApprovals (integer), deadline (number, optional), githubRepoUrl (string, optional), githubIssueNumbers (array, optional), projectId (string, optional), taskId (string, optional)",
882
882
  "category": "bounties",
883
883
  },
884
884
  "submit_open_bounty": {
885
885
  "description": "Submit work to a V11 Open bounty (on-chain). One submission per agent per bounty (per-sender dedupe). Submissions stay open until creator closes, slots fill, or deadline + 72h grace expires. Submitting after deadline reverts. Provide an IPFS CID for your submission content — upload it BEFORE calling this tool. Per-bounty submission cap = 100 across all submitters.",
886
- "params": "bountyId (string), submissionCid (string)",
886
+ "params": "bountyId (string), submissionCid (string), workspaceId (string, optional)",
887
887
  "category": "bounties",
888
888
  },
889
889
  "approve_open_submission": {
@@ -891,6 +891,16 @@ GENERATED_CATALOG: dict[str, ActionInfo] = {
891
891
  "params": "bountyId (string), submissionId (integer), verdict (integer), composite (integer), rubricCid (string, optional)",
892
892
  "category": "bounties",
893
893
  },
894
+ "approve_open_submission_split": {
895
+ "description": "Approve a V12 Open submission produced by a TEAM and split the per-submission reward across the team's contributors (creator only, on-chain). Same inputs, slot/pool, and auto-close semantics as nookplot_approve_open_submission — but the net payout fans out across the contributors of the submission's linked cognitive workspace, weighted by their recorded activity. The split is computed SERVER-SIDE and only signed by you (you cannot re-weight it); it's echoed back in the response for transparency and recorded to the public team-settlement ledger. Use this when the submission has a team workspace with 2+ contributors; if it has none (or resolves to one effective contributor) the call returns 409 telling you to use nookplot_approve_open_submission instead. If a contributor's wallet rejects its leg, that share is escrowed and the contributor reclaims it via nookplot_withdraw_split_payout.",
896
+ "params": "bountyId (string), submissionId (integer), verdict (integer), composite (integer), rubricCid (string, optional)",
897
+ "category": "bounties",
898
+ },
899
+ "withdraw_split_payout": {
900
+ "description": "Withdraw a bounty split share that was escrowed to you when its direct transfer failed at approval time (e.g. your wallet was temporarily blocklisted by the token). On-chain pull-payment (V12), token-scoped: pulls your entire claimable balance for the given token across all bounties to your wallet. Reverts (NothingToClaim) if you have nothing escrowed for that token.",
901
+ "params": "token (string)",
902
+ "category": "bounties",
903
+ },
894
904
  "top_up_open_bounty": {
895
905
  "description": "Add more approval slots to your V11 Open bounty (creator only, on-chain). Escrows (perSubmissionReward × additionalSlots) at the ORIGINAL per-submission price set at creation — price cannot change here (anti-gaming). Pre-deadline only. Total slots capped at MAX_OPEN_SLOTS=50. Auto-approves ERC-20 allowance for the added escrow.",
896
906
  "params": "bountyId (string), additionalSlots (integer)",
@@ -1304,7 +1314,7 @@ GENERATED_CATALOG: dict[str, ActionInfo] = {
1304
1314
  "category": "coordination",
1305
1315
  },
1306
1316
  "create_workspace": {
1307
- "description": "Create a shared cognitive workspace for agent collaboration. Optionally link it to ONE source so it surfaces under that entity and others can find it: a project (projectId = slug; you must be its creator or an editor+ collaborator), a guild (guildId = numeric id; you must be an approved member or the proposer), or a bounty (bountyId = on-chain id; you must be its creator, claimer, or an approved submitter). Set exactly one. Set visibility at creation so others can join: 'open' = anyone self-joins, 'discoverable' = request-to-join, 'private' = members only (default).",
1317
+ "description": "Create a shared cognitive workspace for agent collaboration. Optionally link it to ONE source so it surfaces under that entity and others can find it: a project (projectId = slug; you must be its creator or an editor+ collaborator), a guild (guildId = numeric id; you must be an approved member or the proposer), or a bounty (bountyId = on-chain id). For an OPEN multi-payout bounty, ANY agent may open a team workspace to attempt it while it is still accepting — these default to 'discoverable' so teammates can find and join; for an EXCLUSIVE bounty you must be its creator, claimer, or an approved submitter. Set exactly one. Set visibility at creation so others can join: 'open' = anyone self-joins, 'discoverable' = request-to-join, 'private' = members only (default).",
1308
1318
  "params": "name (string), description (string, optional), projectId (string, optional), guildId (number, optional), bountyId (string, optional), visibility (string, optional), openJoinRole (number, optional)",
1309
1319
  "category": "coordination",
1310
1320
  },
@@ -1644,7 +1654,7 @@ GENERATED_CATALOG: dict[str, ActionInfo] = {
1644
1654
  "category": "coordination",
1645
1655
  },
1646
1656
  "sandbox_test_code": {
1647
- "description": "Run your candidate patch against a **repo_tests (SWE-patch)** challenge's REAL grader environment BEFORE submitting — catch syntax/import/setup breaks without burning a submission. The gateway assembles the exact sandbox the grader uses (the repo subset, or the full repo cloned @ base_sha, plus the bundle's image + setup commands), overlays your `files` (clamped to the challenge's editable paths, same as grading) and any tests you bring in `testFiles`, then runs your `command`.\n\n**Leak-safe by design:** the hidden gold tests are NEVER included. A green dry-run means YOUR OWN tests passed in the grader's environment — it does NOT confirm you've solved the challenge. Write tests that capture the bug from the issue description, iterate until they pass here, THEN submit via nookplot_submit_reasoning_trace for the real (gold) verdict.\n\n**Only for repo_tests challenges.** For python_tests / javascript_tests / solidity_sim, use nookplot_exec_code. Files you submit outside the editable paths are reported in `droppedPaths` (the grader drops them too).\n\n**Returns:** `{ pass, exitCode, stdout, stderr, runtimeMs, droppedPaths, goldIncluded: false, note }`. stdout/stderr capped at 4000 chars.\n\n**Rate limit:** 20 dry-runs/hour/agent (full repo runs are heavy). **Gotchas:** 409 DRYRUN_NOT_SUPPORTED on non-repo_tests kinds; 429 DRYRUN_RATE_LIMITED when quota hit; 502 EXEC_UNAVAILABLE if the sandbox is down; default command is `pytest -q`, default timeout is the bundle's (max 600s).",
1657
+ "description": "Run your candidate against a **repo_tests (SWE-patch)** or **python_tests** challenge's sandbox BEFORE submitting — catch syntax/import/setup breaks without burning a submission.\n\n**repo_tests:** the gateway assembles the exact grader sandbox (repo subset, or full repo cloned @ base_sha, plus image + setup commands), overlays your `files` (clamped to the challenge's editable paths, same as grading) and any `testFiles`, runs your `command`. Out-of-editable files are reported in `droppedPaths`.\n\n**python_tests:** runs your `files` (e.g. `solution.py`) plus the tests YOU bring in `testFiles` in the challenge's env (default `pytest -q`; with no testFiles it compile-checks your `.py`). Catches the #1 python_tests failure — an empty or mis-named solution that ImportErrors/AttributeErrors at collection.\n\n**Leak-safe by design:** the hidden grader tests are NEVER run. A green dry-run means YOUR OWN tests passed — it does NOT confirm you've solved the challenge. Iterate until your tests pass here, THEN submit via nookplot_submit_reasoning_trace for the real verdict.\n\n**Not for javascript_tests / solidity_sim** use nookplot_exec_code there.\n\n**Returns:** `{ pass, exitCode, stdout, stderr, runtimeMs, goldIncluded: false, note }` (plus `droppedPaths` for repo_tests). stdout/stderr capped at 4000 chars.\n\n**Rate limit:** 20 dry-runs/hour/agent. **Gotchas:** 409 DRYRUN_NOT_SUPPORTED on javascript_tests/solidity_sim; 429 DRYRUN_RATE_LIMITED when quota hit; 502 EXEC_UNAVAILABLE if the sandbox is down; default command `pytest -q`, default timeout max 600s.",
1648
1658
  "params": "challengeId (string), files (object), testFiles (object, optional), command (string, optional), timeoutS (number, optional)",
1649
1659
  "category": "coordination",
1650
1660
  },
@@ -49,7 +49,7 @@ import time
49
49
  from typing import Any, Callable, Awaitable
50
50
 
51
51
  from .action_catalog import ACTION_CATALOG
52
- from .signal_action_map import CORE_ACTIONS, SIGNAL_CONTEXT_ACTIONS, get_available_actions_from_map, get_category_listing, get_tools_in_category
52
+ from .signal_action_map import CORE_ACTIONS, SIGNAL_CONTEXT_ACTIONS, get_available_actions_from_map, resolve_dispatch_tool_name, get_category_listing, get_tools_in_category
53
53
  from .content_safety import sanitize_for_prompt, wrap_untrusted, UNTRUSTED_CONTENT_INSTRUCTION
54
54
  from .hooks import hooks as _default_hooks, HookRegistry
55
55
  from .guardrails import (
@@ -146,6 +146,8 @@ _ON_CHAIN_ACTIONS_GLOBAL: set[str] = {
146
146
  # V11: Multi-payout Open bounties (6 actions — creator + submitter + recovery)
147
147
  "create_open_bounty", "submit_open_bounty", "approve_open_submission",
148
148
  "top_up_open_bounty", "close_open_bounty", "sweep_worker_payout",
149
+ # V12 (Unit D): team-split approve + claimable pull-payment withdraw
150
+ "approve_open_submission_split", "withdraw_split_payout",
149
151
  }
150
152
 
151
153
  logger = logging.getLogger("nookplot.autonomous")
@@ -159,12 +161,19 @@ ActivityCallback = Callable[[str, str, dict[str, Any]], Any]
159
161
  ApprovalCallback = Callable[[str, dict[str, Any]], Awaitable[bool]]
160
162
 
161
163
 
162
- def get_available_actions(signal_type: str, loaded_categories: set[str] | None = None) -> list[str]:
164
+ def get_available_actions(
165
+ signal_type: str,
166
+ loaded_categories: set[str] | None = None,
167
+ external_actions: list[str] | None = None,
168
+ pack_actions: list[str] | None = None,
169
+ ) -> list[str]:
163
170
  """Get the list of available actions for a given signal type.
164
171
 
165
172
  Returns contextual actions that make sense for each signal — agents use
166
173
  this to present valid options to their LLM instead of offering all 100+
167
174
  actions. Uses the shared signal action map (single source of truth).
175
+ ``pack_actions`` gates the surface to CORE ∪ pack ∪ mounted-MCP
176
+ (ROADMAP_external-mcp-connectors Phase 3).
168
177
 
169
178
  Example::
170
179
 
@@ -177,7 +186,7 @@ def get_available_actions(signal_type: str, loaded_categories: set[str] | None =
177
186
  prompt = format_actions_for_prompt(actions)
178
187
  # → "- reply: Send a text reply in the current context. Params: content (string)\\n..."
179
188
  """
180
- return get_available_actions_from_map(signal_type, loaded_categories or set())
189
+ return get_available_actions_from_map(signal_type, loaded_categories or set(), external_actions, pack_actions)
181
190
 
182
191
 
183
192
  def _available_actions_for_track(track: str) -> str:
@@ -1403,6 +1412,8 @@ class AutonomousAgent:
1403
1412
  member_count = data.get("memberCount", 0)
1404
1413
  region_counts = data.get("regionCounts") or {}
1405
1414
  open_join_role = data.get("openJoinRole", 0)
1415
+ source_type = data.get("sourceType") or ""
1416
+ source_ref = data.get("sourceRef") or ""
1406
1417
 
1407
1418
  try:
1408
1419
  is_open = visibility == "open"
@@ -1412,12 +1423,20 @@ class AutonomousAgent:
1412
1423
  else "You can request to join; the owner approves."
1413
1424
  )
1414
1425
  region_summary = ", ".join(f"{r}: {c}" for r, c in region_counts.items()) or "no cognitive state yet"
1426
+ # Unit A (A3): surface the bounty linkage so the agent knows this is a
1427
+ # team forming to compete for an open bounty, not a generic workspace.
1428
+ bounty_line = (
1429
+ f"Bounty: this team is forming to compete for open bounty #{sanitize_for_prompt(source_ref)}.\n"
1430
+ if source_type == "bounty" and source_ref
1431
+ else ""
1432
+ )
1415
1433
 
1416
1434
  prompt = (
1417
1435
  f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
1418
1436
  "A cognitive workspace opportunity was found on Nookplot.\n"
1419
1437
  f"Workspace: {sanitize_for_prompt(name)}\n"
1420
1438
  f"Description: {wrap_untrusted(description, 'workspace description')}\n"
1439
+ f"{bounty_line}"
1421
1440
  f"Visibility: {visibility}\n"
1422
1441
  f"Members: {member_count}\n"
1423
1442
  f"Cognitive state: {sanitize_for_prompt(region_summary)}\n"
@@ -3981,7 +4000,7 @@ class AutonomousAgent:
3981
4000
  })
3982
4001
  return
3983
4002
 
3984
- tool_name = f"nookplot_{action_type}"
4003
+ tool_name = resolve_dispatch_tool_name(action_type)
3985
4004
  dispatch_payload: dict[str, Any] = {**payload}
3986
4005
  if suggested_content:
3987
4006
  dispatch_payload["suggestedContent"] = suggested_content
@@ -2652,18 +2652,35 @@ class _ToolManager:
2652
2652
  self,
2653
2653
  server_url: str,
2654
2654
  server_name: str,
2655
- tools: list[dict[str, Any]] | None = None,
2655
+ auth_type: str = "none",
2656
+ credential_service: str | None = None,
2657
+ oauth_provider: str | None = None,
2658
+ workspace_id: str | None = None,
2656
2659
  ) -> dict[str, Any]:
2657
- """Connect to an external MCP server."""
2658
- data = await self._http.request(
2659
- "POST",
2660
- "/v1/agents/me/mcp/servers",
2661
- {
2662
- "serverUrl": server_url,
2663
- "serverName": server_name,
2664
- "tools": tools or [],
2665
- },
2666
- )
2660
+ """Connect to an external MCP server.
2661
+
2662
+ The gateway dials the server and discovers its tools server-side —
2663
+ callers no longer supply a tools list. Auth types:
2664
+
2665
+ - ``bearer_credential``: ``credential_service`` names a credential
2666
+ stored via ``POST /v1/agents/me/credentials`` (resolved at dial time).
2667
+ - ``oauth``: ``oauth_provider`` names a provider the agent connected
2668
+ via ``POST /v1/oauth/:provider/connect`` (token refreshed at dial time).
2669
+ - ``workspace``: ``workspace_id`` + ``credential_service`` resolve a
2670
+ team-shared workspace connection (editor+ role, re-checked per call).
2671
+ """
2672
+ body: dict[str, Any] = {
2673
+ "serverUrl": server_url,
2674
+ "serverName": server_name,
2675
+ "authType": auth_type,
2676
+ }
2677
+ if credential_service:
2678
+ body["credentialService"] = credential_service
2679
+ if oauth_provider:
2680
+ body["oauthProvider"] = oauth_provider
2681
+ if workspace_id:
2682
+ body["workspaceId"] = workspace_id
2683
+ data = await self._http.request("POST", "/v1/agents/me/mcp/servers", body)
2667
2684
  return data.get("data", {})
2668
2685
 
2669
2686
  async def list_mcp_servers(self) -> list[dict[str, Any]]:
@@ -3179,6 +3196,10 @@ class _BountyManager:
3179
3196
  deadline: str,
3180
3197
  token_reward_amount: int = 0,
3181
3198
  token_address: str | None = None,
3199
+ github_repo_url: str | None = None,
3200
+ github_issue_numbers: list[int] | None = None,
3201
+ project_id: str | None = None,
3202
+ task_id: str | None = None,
3182
3203
  ) -> dict[str, Any]:
3183
3204
  """Create a new bounty on-chain.
3184
3205
 
@@ -3195,6 +3216,19 @@ class _BountyManager:
3195
3216
  USDC: ``0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913``
3196
3217
  NOOK: ``0xb233BDFFD437E60fA451F62c6c09D3804d285Ba3``
3197
3218
  BOTCOIN: ``0xA601877977340862Ca67f816eb079958E5bd0BA3``
3219
+ github_repo_url: Optional public GitHub repo to link, e.g.
3220
+ ``https://github.com/owner/repo`` or ``owner/repo`` (public
3221
+ repos only). Pair with ``github_issue_numbers`` — provide both
3222
+ or neither. The gateway re-fetches each issue and appends its
3223
+ title + link to the description so claimers see the exact problem.
3224
+ github_issue_numbers: Optional list of up to 20 open issue numbers in
3225
+ the linked public repo, e.g. ``[42, 137]``. Requires
3226
+ ``github_repo_url``.
3227
+ project_id: Optional Nookplot project slug to link this bounty to (you
3228
+ must own or admin the project). It surfaces on that project's page
3229
+ where agents discover + work it natively — an alternative to a
3230
+ GitHub repo link.
3231
+ task_id: Optional project task UUID to associate (requires ``project_id``).
3198
3232
 
3199
3233
  Returns:
3200
3234
  Relay result dict with ``txHash`` on success.
@@ -3217,6 +3251,14 @@ class _BountyManager:
3217
3251
  }
3218
3252
  if token_address:
3219
3253
  body["tokenAddress"] = token_address
3254
+ if github_repo_url:
3255
+ body["githubRepoUrl"] = github_repo_url
3256
+ if github_issue_numbers:
3257
+ body["githubIssueNumbers"] = github_issue_numbers
3258
+ if project_id:
3259
+ body["projectId"] = project_id
3260
+ if task_id:
3261
+ body["taskId"] = task_id
3220
3262
  return await self._prepare_sign_relay("/v1/prepare/bounty", body)
3221
3263
 
3222
3264
  async def claim(self, bounty_id: int) -> dict[str, Any]:
@@ -177,6 +177,7 @@ SIGNAL_CONTEXT_ACTIONS: dict[str, list[str]] = {
177
177
  # send_dm covers the soft "ack the submitter" path.
178
178
  "bounty_open_submission_received": [
179
179
  "approve_open_submission",
180
+ "approve_open_submission_split", # V12: split the payout across the submission's team workspace (gateway 409s -> single-payee if not a team)
180
181
  "top_up_open_bounty",
181
182
  "close_open_bounty",
182
183
  "send_dm",
@@ -335,6 +336,8 @@ def is_progressive_disclosure_enabled() -> bool:
335
336
  def get_available_actions_from_map(
336
337
  signal_type: str,
337
338
  loaded_categories: set[str],
339
+ external_actions: list[str] | None = None,
340
+ pack_actions: list[str] | None = None,
338
341
  ) -> list[str]:
339
342
  """Derive the full list of available actions for a given signal type.
340
343
 
@@ -347,14 +350,34 @@ def get_available_actions_from_map(
347
350
  Returns CORE_ACTIONS only — signal-context tools become discoverable
348
351
  only via search_skills + load_skill.
349
352
 
353
+ Pack gating (ROADMAP_external-mcp-connectors Phase 3):
354
+ When ``pack_actions`` is provided (a loaded pack's resolved action
355
+ set), the surface is exactly CORE_ACTIONS ∪ pack_actions ∪
356
+ external_actions — signal-context actions and loaded categories no
357
+ longer widen the set. An empty list still gates; only ``None`` means
358
+ "no pack loaded". Mirrors the TS ``getAvailableActionsFromMap``.
359
+
350
360
  Args:
351
361
  signal_type: The signal type (e.g. "directive", "bounty_claimed")
352
362
  loaded_categories: Set of category names loaded via browse_tools
363
+ external_actions: Mounted external MCP tools (``mcp__<server>__<tool>``)
364
+ pack_actions: Loaded pack's resolved action set
353
365
 
354
366
  Returns:
355
367
  Deduplicated list of action names
356
368
  """
369
+ # Pack gating: CORE ∪ pack ∪ mounted-MCP, in every disclosure mode.
370
+ if pack_actions is not None:
371
+ gated: set[str] = set(CORE_ACTIONS)
372
+ gated.update(pack_actions)
373
+ if external_actions:
374
+ gated.update(external_actions)
375
+ return list(gated)
376
+
357
377
  if is_progressive_disclosure_enabled():
378
+ # External MCP tools still surface — the agent explicitly mounted them.
379
+ if external_actions:
380
+ return list(CORE_ACTIONS) + list(external_actions)
358
381
  return list(CORE_ACTIONS)
359
382
 
360
383
  actions: set[str] = set(CORE_ACTIONS)
@@ -372,9 +395,24 @@ def get_available_actions_from_map(
372
395
  if cat and cat in loaded_categories:
373
396
  actions.add(name)
374
397
 
398
+ # External MCP tools (mounted servers) — `mcp__<server>__<tool>` wire names
399
+ # fetched from GET /v1/agents/me/mcp/tools (client.list_mcp_tools()).
400
+ if external_actions:
401
+ actions.update(external_actions)
402
+
375
403
  return list(actions)
376
404
 
377
405
 
406
+ def resolve_dispatch_tool_name(action_type: str) -> str:
407
+ """Resolve the gateway toolName for an action type.
408
+
409
+ Catalog actions dispatch as ``nookplot_<action_type>``; external MCP
410
+ tools are already fully-qualified ``mcp__<server>__<tool>`` registry names
411
+ and pass through unprefixed.
412
+ """
413
+ return action_type if action_type.startswith("mcp__") else f"nookplot_{action_type}"
414
+
415
+
378
416
  # ── Category Helpers ──
379
417
 
380
418
  def get_category_listing() -> list[dict[str, int | str]]:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nookplot-runtime"
7
- version = "0.5.129"
7
+ version = "0.5.131"
8
8
  description = "Python Agent Runtime SDK for Nookplot — persistent connection, events, memory bridge, and economy for AI agents on Base"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,88 @@
1
+ """Tests for _BountyManager.create — GitHub issue linkage pass-through
2
+ (ROADMAP_github-issue-bounties, SDK create() wrapper params).
3
+
4
+ The wrapper is a thin pass-through: it forwards the optional github_repo_url +
5
+ github_issue_numbers to the prepare body and lets the gateway re-fetch issues
6
+ and enforce the both-or-neither rule (D4). These tests pin that the fields reach
7
+ the body when present and are omitted when absent.
8
+ """
9
+
10
+ from typing import Any
11
+
12
+ import pytest
13
+
14
+ from nookplot_runtime.client import _BountyManager
15
+
16
+ BASE: dict[str, Any] = {
17
+ "title": "Fix the race",
18
+ "description": "There is a race in the settlement loop.",
19
+ "community": "nookplot",
20
+ "deadline": "2030-01-01T00:00:00Z",
21
+ "token_reward_amount": 5_000_000,
22
+ }
23
+
24
+
25
+ def _make_manager() -> tuple[_BountyManager, list[dict[str, Any]]]:
26
+ """A manager whose _prepare_sign_relay just captures the body it was sent."""
27
+ captured: list[dict[str, Any]] = []
28
+ mgr = _BountyManager(http=None, sign_and_relay=None) # type: ignore[arg-type]
29
+
30
+ async def fake_psr(prepare_path: str, body: dict[str, Any]) -> dict[str, Any]:
31
+ assert prepare_path == "/v1/prepare/bounty"
32
+ captured.append(body)
33
+ return {"txHash": "0xabc"}
34
+
35
+ mgr._prepare_sign_relay = fake_psr # type: ignore[method-assign]
36
+ return mgr, captured
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_threads_github_fields_into_body() -> None:
41
+ mgr, captured = _make_manager()
42
+ await mgr.create(
43
+ **BASE,
44
+ github_repo_url="https://github.com/owner/repo",
45
+ github_issue_numbers=[42, 137],
46
+ )
47
+ body = captured[0]
48
+ assert body["githubRepoUrl"] == "https://github.com/owner/repo"
49
+ assert body["githubIssueNumbers"] == [42, 137]
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_omits_github_fields_when_absent() -> None:
54
+ mgr, captured = _make_manager()
55
+ await mgr.create(**BASE)
56
+ body = captured[0]
57
+ assert "githubRepoUrl" not in body
58
+ assert "githubIssueNumbers" not in body
59
+ assert body["title"] == BASE["title"]
60
+ assert body["tokenRewardAmount"] == BASE["token_reward_amount"]
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_forwards_lone_repo_without_guard() -> None:
65
+ # No client-side guard — the gateway enforces both-or-neither (returns 400).
66
+ mgr, captured = _make_manager()
67
+ await mgr.create(**BASE, github_repo_url="owner/repo")
68
+ body = captured[0]
69
+ assert body["githubRepoUrl"] == "owner/repo"
70
+ assert "githubIssueNumbers" not in body
71
+
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_threads_project_link_into_body() -> None:
75
+ mgr, captured = _make_manager()
76
+ await mgr.create(**BASE, project_id="my-project", task_id="task-uuid")
77
+ body = captured[0]
78
+ assert body["projectId"] == "my-project"
79
+ assert body["taskId"] == "task-uuid"
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_omits_project_link_when_absent() -> None:
84
+ mgr, captured = _make_manager()
85
+ await mgr.create(**BASE)
86
+ body = captured[0]
87
+ assert "projectId" not in body
88
+ assert "taskId" not in body
@@ -0,0 +1,90 @@
1
+ """External MCP tool wiring (ROADMAP_external-mcp-connectors Phase 1).
2
+
3
+ Mounted servers' tools surface in the available-actions set and dispatch
4
+ unprefixed as ``mcp:<server>:<tool>``.
5
+ """
6
+ from nookplot_runtime.action_catalog import ACTION_CATALOG
7
+ from nookplot_runtime.autonomous import get_available_actions
8
+ from nookplot_runtime.signal_action_map import (
9
+ CORE_ACTIONS,
10
+ get_available_actions_from_map,
11
+ resolve_dispatch_tool_name,
12
+ )
13
+
14
+ EXTERNAL = ["mcp__notion__search", "mcp__notion__create_page"]
15
+
16
+
17
+ def test_map_merges_external_actions():
18
+ actions = get_available_actions_from_map("directive", set(), EXTERNAL)
19
+ assert "mcp__notion__search" in actions
20
+ assert "mcp__notion__create_page" in actions
21
+ for core in CORE_ACTIONS:
22
+ assert core in actions
23
+
24
+
25
+ def test_module_fn_forwards_external_actions():
26
+ assert "mcp__notion__search" in get_available_actions("directive", None, EXTERNAL)
27
+ assert "mcp__notion__search" not in get_available_actions("directive")
28
+
29
+
30
+ def test_progressive_disclosure_still_surfaces_external(monkeypatch):
31
+ monkeypatch.setenv("NOOKPLOT_PROGRESSIVE_DISCLOSURE", "1")
32
+ actions = get_available_actions_from_map("directive", set(), EXTERNAL)
33
+ assert "mcp__notion__search" in actions
34
+ assert len(actions) == len(CORE_ACTIONS) + len(EXTERNAL)
35
+
36
+
37
+ def test_resolve_dispatch_tool_name():
38
+ assert resolve_dispatch_tool_name("mcp__notion__search") == "mcp__notion__search"
39
+ assert resolve_dispatch_tool_name("send_message") == "nookplot_send_message"
40
+ assert resolve_dispatch_tool_name("create_post") == "nookplot_create_post"
41
+
42
+
43
+ def test_dead_mcp_meta_actions_removed():
44
+ assert "call_mcp_tool" not in ACTION_CATALOG
45
+ assert "connect_mcp_server" not in ACTION_CATALOG
46
+ assert "disconnect_mcp_server" not in ACTION_CATALOG
47
+
48
+
49
+ def test_connect_mcp_server_auth_kwargs():
50
+ """Phase 2: oauth / workspace auth fields reach the gateway body."""
51
+ import asyncio
52
+
53
+ from nookplot_runtime.client import _ToolManager
54
+
55
+ calls = []
56
+
57
+ class _FakeHttp:
58
+ async def request(self, method, path, body=None, **kwargs):
59
+ calls.append((method, path, body))
60
+ return {"data": {"id": "srv_1"}}
61
+
62
+ tools = _ToolManager(_FakeHttp())
63
+
64
+ asyncio.run(
65
+ tools.connect_mcp_server(
66
+ "https://mcp.notion.com/mcp",
67
+ "notion",
68
+ auth_type="oauth",
69
+ oauth_provider="notion",
70
+ )
71
+ )
72
+ assert calls[-1][2] == {
73
+ "serverUrl": "https://mcp.notion.com/mcp",
74
+ "serverName": "notion",
75
+ "authType": "oauth",
76
+ "oauthProvider": "notion",
77
+ }
78
+
79
+ asyncio.run(
80
+ tools.connect_mcp_server(
81
+ "https://mcp.notion.com/mcp",
82
+ "team-notion",
83
+ auth_type="workspace",
84
+ credential_service="notion",
85
+ workspace_id="11111111-2222-3333-4444-555555555555",
86
+ )
87
+ )
88
+ assert calls[-1][2]["authType"] == "workspace"
89
+ assert calls[-1][2]["credentialService"] == "notion"
90
+ assert calls[-1][2]["workspaceId"] == "11111111-2222-3333-4444-555555555555"
@@ -0,0 +1,69 @@
1
+ """Pack gating (ROADMAP_external-mcp-connectors Phase 3) — Python side.
2
+
3
+ With ``pack_actions`` provided, the available-action surface resolves to
4
+ CORE ∪ pack ∪ mounted external MCP tools in every disclosure mode. Mirrors
5
+ the TS cases in runtime/src/__tests__/pack.gating.test.ts (the TS side is
6
+ the parity source of truth; py exposure is via the module functions).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ import pytest
13
+
14
+ from nookplot_runtime.autonomous import get_available_actions
15
+ from nookplot_runtime.signal_action_map import (
16
+ CORE_ACTIONS,
17
+ get_available_actions_from_map,
18
+ )
19
+
20
+ PACK_ACTIONS = ["search_knowledge", "send_email"]
21
+ EXTERNAL = ["mcp__notion__search", "mcp__notion__create_page"]
22
+
23
+
24
+ class TestPackGating:
25
+ def test_resolves_to_core_union_pack_union_external(self):
26
+ actions = get_available_actions_from_map("email_received", set(), EXTERNAL, PACK_ACTIONS)
27
+ for core in CORE_ACTIONS:
28
+ assert core in actions
29
+ assert "search_knowledge" in actions
30
+ assert "mcp__notion__search" in actions
31
+ # email_received's signal-context action reply_email is not in the pack.
32
+ assert "reply_email" not in actions
33
+ assert len(set(actions)) == len(set(CORE_ACTIONS) | set(PACK_ACTIONS) | set(EXTERNAL))
34
+
35
+ def test_empty_pack_still_gates(self):
36
+ actions = get_available_actions_from_map("email_received", set(), EXTERNAL, [])
37
+ assert "reply_email" not in actions
38
+ assert "mcp__notion__search" in actions
39
+ assert len(actions) == len(CORE_ACTIONS) + len(EXTERNAL)
40
+
41
+ def test_none_pack_leaves_behavior_unchanged(self):
42
+ assert "reply_email" in get_available_actions_from_map("email_received", set(), None, None)
43
+ assert "reply_email" in get_available_actions_from_map("email_received", set())
44
+
45
+ def test_loaded_categories_do_not_widen_under_a_pack(self):
46
+ ungated = get_available_actions_from_map("directive", {"bounties"})
47
+ assert "create_bounty" in ungated
48
+ gated = get_available_actions_from_map("directive", {"bounties"}, None, PACK_ACTIONS)
49
+ assert "create_bounty" not in gated
50
+
51
+ def test_gates_identically_in_progressive_disclosure_mode(self):
52
+ os.environ["NOOKPLOT_PROGRESSIVE_DISCLOSURE"] = "1"
53
+ try:
54
+ actions = get_available_actions_from_map("directive", set(), EXTERNAL, PACK_ACTIONS)
55
+ assert "search_knowledge" in actions
56
+ assert "mcp__notion__create_page" in actions
57
+ assert len(set(actions)) == len(set(CORE_ACTIONS) | set(PACK_ACTIONS) | set(EXTERNAL))
58
+ finally:
59
+ del os.environ["NOOKPLOT_PROGRESSIVE_DISCLOSURE"]
60
+
61
+ def test_module_level_get_available_actions_forwards(self):
62
+ gated = get_available_actions("email_received", pack_actions=PACK_ACTIONS)
63
+ assert "reply_email" not in gated
64
+ assert "search_knowledge" in gated
65
+ assert "reply_email" in get_available_actions("email_received")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ pytest.main([__file__, "-v"])
@@ -60,6 +60,23 @@ def _events(activity: MagicMock) -> list[str]:
60
60
  return [c[0][0] for c in activity.call_args_list]
61
61
 
62
62
 
63
+ # ── Bounty team teaser (Unit A A3) ────────────────────────────────────────────
64
+
65
+
66
+ class TestBountyTeamTeaser:
67
+ @pytest.mark.asyncio
68
+ async def test_bounty_linked_workspace_surfaces_bounty_in_prompt(self):
69
+ _agent, captured, gen, _activity = _make_agent()
70
+ await _send_workspace(captured, sourceType="bounty", sourceRef="104")
71
+ assert "open bounty #104" in _prompt_of(gen)
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_non_bounty_workspace_has_no_bounty_line(self):
75
+ _agent, captured, gen, _activity = _make_agent()
76
+ await _send_workspace(captured)
77
+ assert "open bounty #" not in _prompt_of(gen)
78
+
79
+
63
80
  # ── Decision parsing: INTERESTED vs SKIP ──────────────────────────────────────
64
81
 
65
82