@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,279 @@
1
+ """deploy — idempotent, HARD-gated on check; stage the COMPLETE standalone unit (slice 9).
2
+
3
+ ``composer deploy <graph.yaml> --out <dir>`` is the compile-on-deploy step. It:
4
+
5
+ 1. Runs ``composer check`` and REFUSES on RED (verdict ``REFUSED``). There is no
6
+ ``--force`` — a graph that isn't GREEN never deploys, by construction (the whole
7
+ trust-by-construction premise: you cannot ship an unverified strategy). Because
8
+ ``check`` now includes the ``runtime_validate`` stage, a unit that is not Zod-valid or
9
+ not PROTECTED (CMP120) never deploys either.
10
+ 2. Stages a COMPLETE, standalone strategy PACKAGE TREE (design §6) into ``<out>/<strategy>/``:
11
+ strategy.yaml catalog + funding manifest, single instance (§7).
12
+ main/runtime.yaml the complete emitted runtime (strategy/scanners/actions/exit/risk).
13
+ main/scanners/scan.py the compiled module (the scaffold `entrypoint`).
14
+ main/scanners/strategy_primitives/ a vendored SNAPSHOT of composer/primitives/ (a
15
+ sibling package of scan.py) — what makes the unit self-contained:
16
+ scan.py imports `strategy_primitives`, never `composer.*`, so it
17
+ runs on a box that has none of the composer checkout.
18
+ main/<strategy>.graph.yaml the spec-is-state graph source (D7), under the instance.
19
+ manifest.json content hashes (graph + scan + runtime.yaml + every vendored
20
+ primitives file), schema version, node count, and the zod-oracle
21
+ verdict for the staged runtime.
22
+ deploy.json the verdict describing exactly what happened.
23
+ 3. Is IDEMPOTENT: re-deploying with identical graph/scan/runtime bytes is a no-op with
24
+ verdict ``UNCHANGED`` — the spec file is the identity, matching status's staleness derivation.
25
+
26
+ The staged unit is COMPLETE: it carries its own ``exit`` (DSL protection), its ``OPEN_POSITION``
27
+ entry action, and a funded-wallet manifest — no host runtime.yaml to borrow (the slice-8 wrap
28
+ hack is retired). Deploy STAGES only; ``composer install`` (``composer/verbs/install.py``) is the
29
+ box-side step that copies one instance of this staged unit to a content-addressed immutable dir,
30
+ binds a ``--wallet`` for the ``${..._WALLET}`` placeholder, and hands it to the frozen
31
+ ``openclaw senpi runtime create`` consumer. Ops (``senpi-strategy-ops``) drives the multi-instance
32
+ handoff: it reads ``strategy.yaml.instances[]``, mints one wallet per instance, funds by
33
+ ``funding_share``, and installs each. The composer never creates wallets or moves money
34
+ (design §7 scope boundary).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import json
41
+ import os
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+ from typing import Optional
45
+
46
+ from composer.authoring.runtime_emit import answers_from_metadata, render_runtime_yaml
47
+ from composer.authoring.strategy_manifest import render_strategy_yaml
48
+ from composer.registry import SCHEMA_VERSION
49
+ from composer.validator.loader import load_graph
50
+ from composer.vendoring import VENDORED_PACKAGE, vendor_primitives
51
+
52
+ from .check import CheckResult, run_check
53
+ from .runtime_validate import validate_runtime_yaml_file
54
+
55
+ DEPLOYED = "DEPLOYED"
56
+ UNCHANGED = "UNCHANGED"
57
+ REFUSED = "REFUSED"
58
+
59
+ # How ops installs the staged unit (documented handoff, not invoked here).
60
+ _OPS_MECHANISM = "senpi-strategy-ops deploy.py create/runtime — one wallet per instance, then `openclaw senpi runtime create`"
61
+
62
+
63
+ def _sha256(text: str) -> str:
64
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
65
+
66
+
67
+ def _aggregate_sha(hashes: dict) -> str:
68
+ """One stable identity over a set of ``{relpath: filesha}`` entries (sorted relpaths), so
69
+ the vendored primitives snapshot gets a single content hash in the manifest."""
70
+ h = hashlib.sha256()
71
+ for rel in sorted(hashes):
72
+ h.update(rel.encode("utf-8"))
73
+ h.update(b"\0")
74
+ h.update(hashes[rel].encode("utf-8"))
75
+ h.update(b"\n")
76
+ return h.hexdigest()
77
+
78
+
79
+ @dataclass
80
+ class DeployResult:
81
+ source: str
82
+ verdict: str
83
+ check: CheckResult
84
+ staged_dir: Optional[str] = None
85
+ manifest: Optional[dict] = None
86
+
87
+ @property
88
+ def ok(self) -> bool:
89
+ return self.verdict in (DEPLOYED, UNCHANGED)
90
+
91
+ def to_json(self) -> dict:
92
+ out: dict = {
93
+ "verb": "deploy",
94
+ "graph": self.source,
95
+ "verdict": self.verdict,
96
+ "check": {
97
+ "verdict": self.check.verdict,
98
+ "error_count": self.check.to_json()["error_count"],
99
+ },
100
+ }
101
+ if self.verdict == REFUSED:
102
+ out["check"]["errors"] = self.check.to_json()["errors"]
103
+ out["message"] = "check RED — deploy refused (no --force; a graph must be GREEN)."
104
+ return out
105
+ man = self.manifest or {}
106
+ out["staged"] = {
107
+ "dir": self.staged_dir,
108
+ "artifacts": sorted(man.get("artifacts", {}).keys()),
109
+ "graph_sha256": man.get("graph_sha256"),
110
+ "scan_sha256": man.get("scan_sha256"),
111
+ "runtime_sha256": man.get("runtime_sha256"),
112
+ "strategy_sha256": man.get("strategy_sha256"),
113
+ "schema_version": man.get("schema_version"),
114
+ "runtime_zod_verdict": man.get("runtime", {}).get("zod_verdict"),
115
+ }
116
+ # The staged unit is COMPLETE and standalone — its own exit + entry action + funding
117
+ # manifest. Ops installs it; there is no host runtime.yaml to borrow (the wrap hack is gone).
118
+ out["install"] = {
119
+ "wired": False,
120
+ "handoff": _OPS_MECHANISM,
121
+ "complete_unit": True,
122
+ "note": (
123
+ "a COMPLETE standalone strategy runtime is staged: main/runtime.yaml carries the "
124
+ "OPEN_POSITION entry action AND the DSL exit (protection-by-default), plus a "
125
+ "position_tracker. Hand the staged dir to senpi-strategy-ops: it reads "
126
+ "strategy.yaml.instances[], mints one funded wallet per instance (funding_share), "
127
+ "and renders each runtime.yaml onto its wallet. The composer never funds or installs."
128
+ ),
129
+ }
130
+ # Every OK verdict names the ABSOLUTE staged dir; UNCHANGED says so in the plainest terms
131
+ # (nothing was rewritten — so an author who sees no fresh mtimes isn't left wondering).
132
+ out["message"] = (
133
+ f"{self.verdict.lower()}: complete unit staged to {self.staged_dir}"
134
+ if self.verdict == DEPLOYED
135
+ else f"unchanged: already staged at {self.staged_dir}, nothing rewritten "
136
+ f"(idempotent no-op — the graph/scan/runtime bytes match the prior staging)."
137
+ )
138
+ return out
139
+
140
+ def render(self) -> str:
141
+ if self.verdict == REFUSED:
142
+ return (
143
+ f"REFUSED: {self.source} — check is {self.check.verdict} "
144
+ f"({self.check.to_json()['error_count']} error(s)); deploy is hard-gated on a "
145
+ f"GREEN check (there is no --force). Run `composer check {self.source}`.\n"
146
+ )
147
+ j = self.to_json()
148
+ s = j["staged"]
149
+ # The absolute staged dir leads EVERY ok verdict; UNCHANGED spells out that nothing was
150
+ # rewritten (the idempotent no-op) so an empty-looking mtime never reads as a silent failure.
151
+ headline = (
152
+ f"UNCHANGED: {self.source} — already staged at {self.staged_dir}, nothing rewritten "
153
+ f"(idempotent no-op)."
154
+ if self.verdict == UNCHANGED
155
+ else f"DEPLOYED: {self.source} — complete unit staged."
156
+ )
157
+ lines = [
158
+ headline,
159
+ f" staged dir : {self.staged_dir}",
160
+ f" artifacts : {', '.join(s['artifacts'])}",
161
+ f" graph sha : {(s['graph_sha256'] or '')[:12]}…",
162
+ f" scan sha : {(s['scan_sha256'] or '')[:12]}…",
163
+ f" runtime sha: {(s['runtime_sha256'] or '')[:12]}… (zod: {s['runtime_zod_verdict']})",
164
+ f" install : complete standalone unit — hand off to ops ({_OPS_MECHANISM}).",
165
+ ]
166
+ return "\n".join(lines) + "\n"
167
+
168
+
169
+ def run_deploy(source_text: str, source_name: str, out_dir) -> DeployResult:
170
+ """Check, then stage the complete standalone unit as a package tree (idempotent). See docstring."""
171
+ check = run_check(source_text, source_name)
172
+ if not check.ok:
173
+ return DeployResult(source_name, REFUSED, check)
174
+
175
+ doc, _line_index = load_graph(source_text)
176
+ metadata = doc.get("metadata") or {}
177
+ strategy = str(metadata.get("strategy") or Path(source_name).stem.replace(".graph", ""))
178
+ archetype = str(metadata.get("archetype") or "unknown")
179
+
180
+ # The runtime.yaml + strategy.yaml derive from the SAME metadata.answers the scan.py did
181
+ # (design §6): recompiling regenerates all three byte-identically.
182
+ answers = answers_from_metadata(metadata)
183
+ scan_source = check.scan_source or ""
184
+ runtime_source = check.runtime_source or render_runtime_yaml(answers)
185
+ strategy_source = render_strategy_yaml(answers)
186
+
187
+ graph_sha = _sha256(source_text)
188
+ scan_sha = _sha256(scan_source)
189
+ runtime_sha = _sha256(runtime_source)
190
+ strategy_sha = _sha256(strategy_source)
191
+
192
+ # Absolutize the staging root against the CURRENT working dir BEFORE composing the tree, so
193
+ # every reported ``staged_dir`` is an ABSOLUTE path the caller can `cd` into — a relative
194
+ # ``-o`` (e.g. the default ``dist``) resolved against an unexpected cwd was the "deploy exited
195
+ # 0 but there are no files where I looked" failure. ``abspath`` absolutizes against the current
196
+ # working dir WITHOUT resolving symlinks (so a caller-supplied absolute path is returned
197
+ # byte-identical, only a relative one is joined onto cwd); the child runs in the caller's real
198
+ # cwd (the rim never sets one), so this is the caller's own working dir.
199
+ stage_dir = Path(os.path.abspath(out_dir)) / strategy
200
+ instance_dir = stage_dir / "main"
201
+ scanners_dir = instance_dir / "scanners"
202
+ manifest_path = stage_dir / "manifest.json"
203
+ graph_filename = f"{strategy}.graph.yaml"
204
+
205
+ # Idempotency: the spec file IS the identity (D7). A prior staging whose manifest records
206
+ # the same graph + scan + runtime bytes is an UNCHANGED no-op — don't rewrite anything.
207
+ if manifest_path.is_file():
208
+ try:
209
+ prior = json.loads(manifest_path.read_text(encoding="utf-8"))
210
+ except (OSError, json.JSONDecodeError):
211
+ prior = {}
212
+ if (
213
+ prior.get("graph_sha256") == graph_sha
214
+ and prior.get("scan_sha256") == scan_sha
215
+ and prior.get("runtime_sha256") == runtime_sha
216
+ ):
217
+ return DeployResult(source_name, UNCHANGED, check,
218
+ staged_dir=str(stage_dir), manifest=prior)
219
+
220
+ manifest = {
221
+ "strategy": strategy,
222
+ "archetype": archetype,
223
+ "schema_version": SCHEMA_VERSION,
224
+ "node_count": len(doc.get("nodes") or []),
225
+ "graph_sha256": graph_sha,
226
+ "scan_sha256": scan_sha,
227
+ "runtime_sha256": runtime_sha,
228
+ "strategy_sha256": strategy_sha,
229
+ "artifacts": {
230
+ "strategy.yaml": strategy_sha,
231
+ "main/runtime.yaml": runtime_sha,
232
+ "main/scanners/scan.py": scan_sha,
233
+ f"main/{graph_filename}": graph_sha,
234
+ },
235
+ "runtime": {"path": "main/runtime.yaml", "sha256": runtime_sha},
236
+ "generated_by": "composer deploy",
237
+ }
238
+
239
+ # Write the complete package tree.
240
+ scanners_dir.mkdir(parents=True, exist_ok=True)
241
+ (scanners_dir / "scan.py").write_text(scan_source, encoding="utf-8")
242
+ # Vendor a SNAPSHOT of the primitives beside scan.py so the staged unit is fully
243
+ # self-contained: the scaffold puts the scanner dir on sys.path, so scan.py's
244
+ # `import strategy_primitives` resolves with NOTHING from the composer checkout present.
245
+ # The copy freezes the primitives at deploy time (future primitives changes never mutate
246
+ # an already-staged strategy — and the idempotency triple below, keyed on graph/scan/
247
+ # runtime bytes, keeps a redeploy of the SAME graph UNCHANGED, so the frozen snapshot stays).
248
+ # Every vendored file folds into the manifest deterministically (sorted relpaths).
249
+ vendored = vendor_primitives(scanners_dir)
250
+ manifest["artifacts"].update(
251
+ {f"main/scanners/{rel}": sha for rel, sha in vendored.items()}
252
+ )
253
+ manifest["primitives"] = {
254
+ "package": f"main/scanners/{VENDORED_PACKAGE}",
255
+ "file_count": len(vendored),
256
+ "sha256": _aggregate_sha(vendored),
257
+ }
258
+ (instance_dir / "runtime.yaml").write_text(runtime_source, encoding="utf-8")
259
+ (instance_dir / graph_filename).write_text(source_text, encoding="utf-8")
260
+ (stage_dir / "strategy.yaml").write_text(strategy_source, encoding="utf-8")
261
+
262
+ # Prove the STAGED runtime.yaml validates against the real oracle; record the verdict.
263
+ ov = validate_runtime_yaml_file(instance_dir / "runtime.yaml")
264
+ manifest["runtime"]["zod_verdict"] = (
265
+ "SKIPPED" if ov.skipped else ("VALID" if ov.ok else "INVALID")
266
+ )
267
+
268
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
269
+
270
+ result = DeployResult(source_name, DEPLOYED, check, staged_dir=str(stage_dir), manifest=manifest)
271
+ (stage_dir / "deploy.json").write_text(
272
+ json.dumps(result.to_json(), indent=2, sort_keys=True) + "\n", encoding="utf-8"
273
+ )
274
+ return result
275
+
276
+
277
+ def deploy_file(path, out_dir) -> DeployResult:
278
+ p = Path(path)
279
+ return run_deploy(p.read_text(encoding="utf-8"), p.name, out_dir)
@@ -0,0 +1,30 @@
1
+ """docs — the catalog / describe verbs (the registry-generated progressive-disclosure surface).
2
+
3
+ ``composer catalog`` prints the ~1-2k-token index (node names + one-liners + param
4
+ summaries); ``composer describe <node>`` prints one node's full ports/params/example.
5
+
6
+ Both read straight from the committed ``composer/generated/`` artifacts when present (they
7
+ are byte-identical to a fresh render — a golden test enforces that), and fall back to
8
+ rendering from the registry in-memory otherwise, so the verbs work in any checkout.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from composer.registry import generate
16
+
17
+ _GENERATED = Path(__file__).resolve().parents[1] / "generated"
18
+
19
+
20
+ def catalog_text() -> str:
21
+ """The catalog index (prefer the committed artifact; render from the registry if absent)."""
22
+ path = _GENERATED / "catalog.md"
23
+ if path.is_file():
24
+ return path.read_text(encoding="utf-8")
25
+ return generate.render_catalog()
26
+
27
+
28
+ def describe_text(node: str) -> str:
29
+ """One node's reference block. Raises KeyError (with a nearest-name hint) if unknown."""
30
+ return generate.render_describe_node(node)