@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548

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 (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,311 @@
1
+ """mcp_closure — the CI drift check that keeps "the source vocabulary FULLY maps the
2
+ scanner-usable MCP read surface" PROVABLE, continuously (registry-mapping-audit.md Wave 3a).
3
+
4
+ The read boundary is a blocklist on the server (senpi_mcp.py MUTATION_TOOLS), so this
5
+ codebase can never prove completeness from its own side alone — it needs the authoritative
6
+ tool catalog. The live prod ``tools/list`` is unauthenticated, so we can: a committed
7
+ SNAPSHOT (``mcp-tools-snapshot.json``: tool names + a hash of each input schema) is diffed
8
+ against the registry mapping (every source NodeDef declares the tool it wraps in the
9
+ machine-readable ``NodeDef.mcp_tool`` field). The check reports three drifts:
10
+
11
+ unmapped reads — a tool in the snapshot that is neither MAPPED by a source node nor on
12
+ the committed ALLOWLIST (mutations + not-scanner-shaped reads). This is
13
+ the load-bearing one: a NEW scanner-usable read appearing server-side,
14
+ or a read we forgot to map, fails CI until it is mapped or allowlisted.
15
+ mapped-but-gone — a tool a source node maps that is ABSENT from the snapshot (removed /
16
+ renamed server-side; the wrapping node is now dead).
17
+ schema drift — (refresh path only) a mapped/known tool whose LIVE input-schema hash
18
+ differs from the committed snapshot; the input contract moved.
19
+
20
+ The offline comparison (``compare``) runs with NO network against the committed snapshot —
21
+ that is the default pytest. An OPT-IN refresh (``MCP_SNAPSHOT_REFRESH=1``) fetches the live
22
+ unauthenticated ``tools/list`` and rewrites the snapshot, surfacing schema drift + tool
23
+ add/remove; network is NEVER the default test path.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+
33
+ SNAPSHOT_PATH = Path(__file__).resolve().parent / "mcp-tools-snapshot.json"
34
+ PROD_MCP_URL = "https://mcp.prod.senpi.ai/mcp"
35
+
36
+ # The money/state MUTATION tools, MIRRORED composer-side from the scaffold's authoritative
37
+ # blocklist (src/scanners/external/scaffold/senpi_mcp.py `MUTATION_TOOLS`). This is the
38
+ # defense-in-depth used by the mcp_read escape hatch's CHECK-time validation (CMP061): a
39
+ # scanner is a read-only producer, so mcp_read refuses any tool named here before it ever
40
+ # reaches a box (the scaffold's own call_tool blocklist is the runtime double-guard). The
41
+ # scaffold source is the single source of truth — a sync test
42
+ # (test_mcp_read_hatch.py::test_mutation_blocklist_matches_scaffold) AST-parses the scaffold
43
+ # frozenset and fails if this duplicate drifts, so a new mutation added there cannot silently
44
+ # slip past the composer-side check.
45
+ MUTATION_TOOLS: frozenset[str] = frozenset(
46
+ {
47
+ "create_position",
48
+ "close_position",
49
+ "edit_position",
50
+ "cancel_order",
51
+ "send_usdc",
52
+ "transfer_spot_to_perps",
53
+ "strategy_create",
54
+ "strategy_create_custom_strategy",
55
+ "strategy_close",
56
+ "strategy_close_positions",
57
+ "strategy_update",
58
+ "strategy_pause",
59
+ "strategy_top_up",
60
+ "strategy_withdraw_funds",
61
+ "strategy_bridge_funds_from_hyperliquid_to_evm",
62
+ "ratchet_stop_add",
63
+ "ratchet_stop_edit",
64
+ "ratchet_stop_delete",
65
+ "user_claim_referral_rewards",
66
+ }
67
+ )
68
+
69
+ # The committed ALLOWLIST: every catalog tool that is deliberately NOT a scanner source, with
70
+ # a reason. Seeded from the registry-mapping-audit classification (senpi_mcp.py MUTATION_TOOLS
71
+ # for the mutations; the audit's "marginal/held" + recon "skip" verdicts for the reads). The
72
+ # closure check treats (snapshot - mapped - allowlist) as "unmapped scanner-usable reads": a
73
+ # non-empty result fails CI. Keep reasons honest — this file is where a maintainer decides a
74
+ # newly-surfaced tool is out of scope.
75
+ ALLOWLIST: dict[str, str] = {
76
+ # --- money/state mutations (senpi_mcp.py MUTATION_TOOLS; blocked for producers) ---
77
+ "create_position": "mutation — opens a position (blocked for producers).",
78
+ "close_position": "mutation — closes a position.",
79
+ "edit_position": "mutation — edits a position.",
80
+ "cancel_order": "mutation — cancels a resting order.",
81
+ "transfer_spot_to_perps": "mutation — moves funds spot->perps.",
82
+ "strategy_create": "mutation — creates a strategy.",
83
+ "strategy_create_custom_strategy": "mutation — creates + funds a strategy wallet.",
84
+ "strategy_close": "mutation — closes a strategy (flattens positions).",
85
+ "strategy_close_positions": "mutation — flattens a strategy's positions.",
86
+ "strategy_update": "mutation — updates strategy config.",
87
+ "strategy_pause": "mutation — pauses a strategy.",
88
+ "strategy_top_up": "mutation — adds funds to a strategy.",
89
+ "strategy_withdraw_funds": "mutation — withdraws strategy funds.",
90
+ "strategy_bridge_funds_from_hyperliquid_to_evm": "mutation — bridges funds HL->EVM.",
91
+ "ratchet_stop_add": "mutation — adds a server-side ratchet stop.",
92
+ "ratchet_stop_edit": "mutation — edits a ratchet stop.",
93
+ "ratchet_stop_delete": "mutation — deletes a ratchet stop.",
94
+ "user_claim_referral_rewards": "mutation — claims referral rewards.",
95
+ # --- reads that are NOT market-signal sources (not scanner-shaped) ---
96
+ "account_get_portfolio": ("own-account current snapshot; clearinghouse_state + "
97
+ "strategy_pnl_snapshot already cover own-wallet state."),
98
+ "arena_pool": "Senpi Arena prize-pool metadata, not a market read.",
99
+ "arena_prizes": "Senpi Arena prize-structure metadata, not a market read.",
100
+ "arena_roe_chart": "Senpi Arena ROE chart data, not a market-signal read.",
101
+ "estimate_custom_strategy_positions_opening": ("pre-trade position-opening estimator "
102
+ "(execution planning), not a signal source."),
103
+ "execution_estimate_position_opening": ("pre-trade position-opening estimator "
104
+ "(execution planning), not a signal source."),
105
+ "execution_get_closed_position_details": ("own-account closed-position execution detail "
106
+ "(ops surface), not a market read."),
107
+ "execution_get_open_position_details": ("own-account open-position execution detail "
108
+ "(ops surface), not a market read."),
109
+ "execution_get_order_status": "own-order status lookup (execution ops), not a signal.",
110
+ "get_loyalty_tiers": "loyalty-program metadata, not a market read.",
111
+ "leaderboard_get_status": "leaderboard service status/health, not trader data.",
112
+ "list_senpi_guides": "documentation/guide listing, not market data.",
113
+ "read_senpi_guide": "documentation/guide content, not market data.",
114
+ "ratchet_stop_events": "own-account ratchet-stop event log; DSL owns exits, not a source.",
115
+ "ratchet_stop_get": "own-account ratchet-stop config read; DSL owns exits.",
116
+ "ratchet_stop_list": "own-account ratchet-stop list; DSL owns exits.",
117
+ "strategy_get": ("single-strategy metadata by id; strategy_list_fetch covers the list "
118
+ "read a scanner would use."),
119
+ "user_get_me": "own-user account read, not a market-signal source.",
120
+ "user_get_referral_rewards": "referral-rewards read, not a market source.",
121
+ "user_get_senpi_points": "Senpi-points read, not a market source.",
122
+ "user_get_senpi_points_leaderboard": "Senpi-points leaderboard, not a market source.",
123
+ }
124
+
125
+
126
+ @dataclass
127
+ class ClosureReport:
128
+ """The outcome of comparing the committed snapshot against the registry mapping."""
129
+
130
+ unmapped_reads: list[str] = field(default_factory=list)
131
+ mapped_but_gone: list[str] = field(default_factory=list)
132
+ stale_allowlist: list[str] = field(default_factory=list)
133
+ overlap_allowlist_mapped: list[str] = field(default_factory=list)
134
+
135
+ @property
136
+ def ok(self) -> bool:
137
+ return not (self.unmapped_reads or self.mapped_but_gone
138
+ or self.stale_allowlist or self.overlap_allowlist_mapped)
139
+
140
+ def render(self) -> str:
141
+ if self.ok:
142
+ return "MCP read surface fully mapped: no drift."
143
+ lines = ["MCP closure drift detected:"]
144
+ if self.unmapped_reads:
145
+ lines.append(" UNMAPPED scanner-usable reads (map them, or allowlist with a reason):")
146
+ lines += [f" - {t}" for t in self.unmapped_reads]
147
+ if self.mapped_but_gone:
148
+ lines.append(" MAPPED-BUT-GONE (tool absent from snapshot — removed/renamed?):")
149
+ lines += [f" - {t}" for t in self.mapped_but_gone]
150
+ if self.overlap_allowlist_mapped:
151
+ lines.append(" CONTRADICTION (tool is both mapped AND allowlisted):")
152
+ lines += [f" - {t}" for t in self.overlap_allowlist_mapped]
153
+ if self.stale_allowlist:
154
+ lines.append(" STALE allowlist (name absent from snapshot — prune it):")
155
+ lines += [f" - {t}" for t in self.stale_allowlist]
156
+ return "\n".join(lines)
157
+
158
+
159
+ def schema_hash(schema) -> str:
160
+ """Deterministic sha256 of an input schema (canonical JSON: sorted keys, tight separators)."""
161
+ return hashlib.sha256(
162
+ json.dumps(schema or {}, sort_keys=True, separators=(",", ":")).encode("utf-8")
163
+ ).hexdigest()
164
+
165
+
166
+ def load_snapshot(path: Path = SNAPSHOT_PATH) -> dict:
167
+ return json.loads(path.read_text(encoding="utf-8"))
168
+
169
+
170
+ def snapshot_tool_names(snapshot: dict | None = None) -> set[str]:
171
+ snapshot = snapshot if snapshot is not None else load_snapshot()
172
+ return set(snapshot.get("tools", {}))
173
+
174
+
175
+ def registry_mapped_tools() -> set[str]:
176
+ """The set of MCP tool names the source registry declares it wraps (NodeDef.mcp_tool)."""
177
+ from .nodes import NODES
178
+ return {n.mcp_tool for n in NODES if n.mcp_tool}
179
+
180
+
181
+ def compare(
182
+ snapshot: dict | None = None,
183
+ mapped: set[str] | None = None,
184
+ allowlist: dict[str, str] | None = None,
185
+ ) -> ClosureReport:
186
+ """Offline comparison of the committed snapshot vs the registry mapping + allowlist."""
187
+ snapshot = snapshot if snapshot is not None else load_snapshot()
188
+ mapped = mapped if mapped is not None else registry_mapped_tools()
189
+ allowlist = allowlist if allowlist is not None else ALLOWLIST
190
+
191
+ names = snapshot_tool_names(snapshot)
192
+ allowset = set(allowlist)
193
+ return ClosureReport(
194
+ unmapped_reads=sorted(names - mapped - allowset),
195
+ mapped_but_gone=sorted(mapped - names),
196
+ stale_allowlist=sorted(allowset - names),
197
+ overlap_allowlist_mapped=sorted(mapped & allowset),
198
+ )
199
+
200
+
201
+ # ------------------------------------------------------------------- OPT-IN live refresh
202
+
203
+ def build_snapshot(tools: list[dict]) -> dict:
204
+ """Trim a raw tools/list into the committed snapshot shape (names + schema hash)."""
205
+ ordered = sorted(tools, key=lambda t: t["name"])
206
+ return {
207
+ "_comment": (
208
+ "Trimmed snapshot of the live prod MCP tools/list (names + input-schema hash "
209
+ "only) for the registry closure check. Regenerate via MCP_SNAPSHOT_REFRESH=1 "
210
+ "pytest python-tests/composer/test_mcp_closure.py. Do not hand-edit."
211
+ ),
212
+ "source": PROD_MCP_URL,
213
+ "tool_count": len(ordered),
214
+ "tools": {t["name"]: {"schema_sha256": schema_hash(t.get("inputSchema"))} for t in ordered},
215
+ }
216
+
217
+
218
+ def diff_snapshots(committed: dict, live: dict) -> dict:
219
+ """added / removed / schema_changed between two snapshots (the refresh drift report)."""
220
+ c = committed.get("tools", {})
221
+ lv = live.get("tools", {})
222
+ added = sorted(set(lv) - set(c))
223
+ removed = sorted(set(c) - set(lv))
224
+ changed = sorted(
225
+ n for n in set(c) & set(lv)
226
+ if c[n].get("schema_sha256") != lv[n].get("schema_sha256")
227
+ )
228
+ return {"added": added, "removed": removed, "schema_changed": changed}
229
+
230
+
231
+ def fetch_live_tools(url: str = PROD_MCP_URL, timeout: float = 20.0) -> list[dict]:
232
+ """Fetch the live unauthenticated tools/list (initialize + tools/list) over MCP streamable
233
+ HTTP. No API key needed. Used only by the opt-in refresh path — never the default test."""
234
+ import urllib.request
235
+
236
+ session_id: dict[str, str] = {}
237
+
238
+ def _post(payload: dict, notify: bool = False):
239
+ body = json.dumps(payload).encode("utf-8")
240
+ headers = {
241
+ "Content-Type": "application/json",
242
+ "Accept": "application/json, text/event-stream",
243
+ }
244
+ if session_id.get("id"):
245
+ headers["mcp-session-id"] = session_id["id"]
246
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
247
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
248
+ sid = resp.headers.get("mcp-session-id")
249
+ if sid:
250
+ session_id["id"] = sid
251
+ raw = resp.read().decode("utf-8")
252
+ if notify:
253
+ return None
254
+ return _parse_jsonrpc(raw)
255
+
256
+ def _parse_jsonrpc(raw: str) -> dict:
257
+ raw = raw.strip()
258
+ if not raw:
259
+ return {}
260
+ if raw.startswith("{"):
261
+ return json.loads(raw)
262
+ # text/event-stream: pull the JSON out of the `data:` lines.
263
+ for line in raw.splitlines():
264
+ line = line.strip()
265
+ if line.startswith("data:"):
266
+ chunk = line[len("data:"):].strip()
267
+ if chunk and chunk != "[DONE]":
268
+ return json.loads(chunk)
269
+ return {}
270
+
271
+ _post({
272
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
273
+ "params": {
274
+ "protocolVersion": "2024-11-05",
275
+ "capabilities": {},
276
+ "clientInfo": {"name": "composer-mcp-closure", "version": "1.0"},
277
+ },
278
+ })
279
+ _post({"jsonrpc": "2.0", "method": "notifications/initialized"}, notify=True)
280
+ result = _post({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
281
+ tools = (result or {}).get("result", {}).get("tools", [])
282
+ if not tools:
283
+ raise RuntimeError(f"tools/list returned no tools (response: {result!r})")
284
+ return tools
285
+
286
+
287
+ def refresh_snapshot(url: str = PROD_MCP_URL, path: Path = SNAPSHOT_PATH) -> tuple[dict, dict]:
288
+ """Fetch live, diff against the committed snapshot, rewrite it. Returns (drift, new_snapshot)."""
289
+ committed = load_snapshot(path) if path.exists() else {"tools": {}}
290
+ live = build_snapshot(fetch_live_tools(url))
291
+ drift = diff_snapshots(committed, live)
292
+ path.write_text(json.dumps(live, indent=2, sort_keys=True) + "\n", encoding="utf-8")
293
+ return drift, live
294
+
295
+
296
+ def main(argv: list[str] | None = None) -> int:
297
+ import sys
298
+ argv = sys.argv[1:] if argv is None else argv
299
+ if "--refresh" in argv:
300
+ drift, _ = refresh_snapshot()
301
+ print("refreshed snapshot; drift vs previous:", json.dumps(drift))
302
+ report = compare()
303
+ print(report.render())
304
+ return 0 if report.ok else 1
305
+ report = compare()
306
+ print(report.render())
307
+ return 0 if report.ok else 1
308
+
309
+
310
+ if __name__ == "__main__":
311
+ raise SystemExit(main())