@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,487 @@
1
+ """cli — the ``composer`` verbs; the Python core's command surface (D7, D8).
2
+
3
+ The composer is driven through a small set of verbs, all deriving their behaviour from the
4
+ registry so the agent never reasons from stale docs:
5
+
6
+ composer new <id> --archetype X ... — interview answers in, a COMPLETE, VALID graph
7
+ out, with mechanically bindable values bound and bespoke slots TODO-marked; a
8
+ feasibility gap report when answers reference unavailable registry names; refuses
9
+ to overwrite an edited graph without ``--force``.
10
+ composer check — validate + port typecheck + fn-node unit tests + compile +
11
+ fixture smoke, collapsed into ONE verdict.
12
+ composer deploy — idempotent, hard-gated on ``check``, targets the existing
13
+ external_scanner deploy path.
14
+ composer status — position DERIVED from artifact + validation + platform state
15
+ (the spec file IS the state; no journals), naming the next verb.
16
+ composer catalog / composer describe <node> — the registry-generated progressive
17
+ disclosure surface (index on demand, full ports/params/example per node).
18
+
19
+ This module is the Python entry point; TypeScript appears only as a thin CLI/verdict
20
+ wrapper (``--json``, deploy gate) in the existing ``@senpi-ai/runtime`` plugin
21
+ (``src/cli/composer-commands.ts``, D8). All verbs are wired as of slice 7: ``new`` and
22
+ ``validate`` (slices 2/6), ``compile`` (slice 3), and the slice-7 lifecycle verbs
23
+ ``check`` / ``deploy`` / ``status`` plus the ``catalog`` / ``describe`` disclosure surface
24
+ (implemented in ``composer.verbs``). Every verb takes ``--json`` for the TS rim.
25
+
26
+ composer validate <file> [--json] — teaching-error report; exit 0 when clean.
27
+ composer check <file> [--json] — ONE verdict (validate+tests+compile+smoke); 0 iff GREEN.
28
+ composer deploy <file> -o <dir> — stage the deployable artifact set; gated on check.
29
+ composer fund <target> --budget N — create + fund the strategy's wallet via MCP
30
+ strategy_create_custom_strategy (preflight balance → create → poll ACTIVE); idempotent,
31
+ writes <state>/strategies/<strategy>/wallet.json (slice B3).
32
+ composer install <target> [--wallet 0x…] — copy a staged unit to a canonical hash dir, bind the
33
+ wallet (explicit --wallet, or auto-bound from a funded wallet.json), and hand it to the
34
+ frozen `openclaw senpi runtime create` consumer (slice A2/B3).
35
+ composer close <target> [--state-dir <dir>] — safe teardown: stop the runtime, confirm gone,
36
+ then trigger MCP strategy_close (flattens positions + returns funds); idempotent (B1).
37
+ composer status <target> [-o <dir>] [--state-dir <dir>] — derived lifecycle position + the next
38
+ verb, extended down the on-box install chain (landed → registered → running) when a box is named.
39
+ composer catalog / describe <node> — the registry-generated progressive-disclosure surface.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import json
46
+ import os
47
+ import sys
48
+ from pathlib import Path
49
+
50
+ from composer.authoring import compose_new, load_answers, load_answers_file
51
+ from composer.compiler import CompileError, compile_file
52
+ from composer.validator import validate_file
53
+ from composer.verbs import (
54
+ catalog_text,
55
+ check_file,
56
+ deploy_file,
57
+ describe_text,
58
+ run_close,
59
+ run_fund,
60
+ run_install,
61
+ run_status,
62
+ )
63
+
64
+
65
+ def _split_csv(value: str) -> list[str]:
66
+ return [item.strip() for item in value.split(",") if item.strip()]
67
+
68
+
69
+ def _new_overrides(args: argparse.Namespace) -> dict:
70
+ """Assemble the CLI-flag override dict (only flags the user actually passed).
71
+
72
+ Flags override the answers-file field-by-field (D7 "individual flag overrides"). Nested
73
+ fields (universe.*, risk.*) are placed under their sub-mapping so the deep-merge in
74
+ ``load_answers`` composes them with the file rather than replacing whole sub-objects.
75
+ """
76
+ over: dict = {"archetype": args.archetype, "strategy": args.id}
77
+ universe: dict = {}
78
+ risk: dict = {}
79
+ if args.whitelist is not None:
80
+ universe["whitelist"] = _split_csv(args.whitelist)
81
+ if args.long_basket is not None:
82
+ universe["long_basket"] = _split_csv(args.long_basket)
83
+ if args.short_basket is not None:
84
+ universe["short_basket"] = _split_csv(args.short_basket)
85
+ if args.discover:
86
+ universe["discover"] = True
87
+ if args.dex is not None:
88
+ universe["dex"] = args.dex
89
+ if args.name_prefix is not None:
90
+ universe["name_prefix"] = args.name_prefix
91
+ if args.max_leverage_cap is not None:
92
+ universe["max_leverage_cap"] = args.max_leverage_cap
93
+ if args.min_day_volume is not None:
94
+ universe["min_day_volume"] = args.min_day_volume
95
+ if universe:
96
+ over["universe"] = universe
97
+ if args.intervals is not None:
98
+ over["intervals"] = _split_csv(args.intervals)
99
+ if args.max_positions is not None:
100
+ over["max_positions"] = args.max_positions
101
+ if args.ttl_seconds is not None:
102
+ over["ttl_seconds"] = args.ttl_seconds
103
+ if args.exit_preset is not None:
104
+ over["exit_preset"] = args.exit_preset
105
+ if args.unmanaged:
106
+ over["unmanaged"] = True
107
+ if args.margin_pct is not None:
108
+ risk["margin_pct"] = args.margin_pct
109
+ if args.leverage is not None:
110
+ risk["leverage"] = args.leverage
111
+ if args.drawdown_halt_pct is not None:
112
+ risk["drawdown_halt_pct"] = args.drawdown_halt_pct
113
+ if args.max_consecutive_losses is not None:
114
+ risk["max_consecutive_losses"] = args.max_consecutive_losses
115
+ if args.cooldown_seconds is not None:
116
+ risk["cooldown_seconds"] = args.cooldown_seconds
117
+ if args.max_entries_per_day is not None:
118
+ risk["max_entries_per_day"] = args.max_entries_per_day
119
+ if risk:
120
+ over["risk"] = risk
121
+ if args.edge is not None:
122
+ over["edge"] = args.edge
123
+ return over
124
+
125
+
126
+ def _cmd_new(args: argparse.Namespace) -> int:
127
+ file_data = load_answers_file(args.answers) if args.answers else None
128
+ try:
129
+ answers = load_answers(file_data, _new_overrides(args))
130
+ except Exception as exc: # pydantic ValidationError etc. — a bad answer, not a crash
131
+ if args.json:
132
+ print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
133
+ else:
134
+ print(f"invalid answers: {exc}", file=sys.stderr)
135
+ return 1
136
+
137
+ out_dir = Path(args.output) if args.output else Path.cwd()
138
+ out_path = out_dir / f"{args.id}.graph.yaml"
139
+ result = compose_new(answers, out_path, force=args.force)
140
+
141
+ if args.json:
142
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
143
+
144
+ if result.gap_report is not None:
145
+ if not args.json:
146
+ print(result.gap_report.render(), end="")
147
+ return 2
148
+ if result.clobber_refused:
149
+ if not args.json:
150
+ print(result.message, file=sys.stderr)
151
+ return 3
152
+ if not result.ok:
153
+ if not args.json:
154
+ print(result.message, file=sys.stderr)
155
+ return 1
156
+
157
+ out_dir.mkdir(parents=True, exist_ok=True)
158
+ out_path.write_text(result.yaml_text, encoding="utf-8")
159
+ if not args.json:
160
+ print(f"wrote {out_path} ({result.message})")
161
+ return 0
162
+
163
+
164
+ def _cmd_validate(args: argparse.Namespace) -> int:
165
+ result = validate_file(args.file)
166
+ if args.json:
167
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
168
+ else:
169
+ print(result.render(), end="")
170
+ return 0 if result.ok else 1
171
+
172
+
173
+ def _cmd_compile(args: argparse.Namespace) -> int:
174
+ try:
175
+ source = compile_file(args.file)
176
+ except CompileError as exc:
177
+ print(str(exc), file=sys.stderr)
178
+ return 1
179
+ if args.output:
180
+ with open(args.output, "w", encoding="utf-8") as fh:
181
+ fh.write(source)
182
+ print(f"wrote {args.output}")
183
+ else:
184
+ print(source, end="")
185
+ return 0
186
+
187
+
188
+ def _cmd_check(args: argparse.Namespace) -> int:
189
+ result = check_file(args.file)
190
+ if args.json:
191
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
192
+ else:
193
+ print(result.render(), end="")
194
+ return 0 if result.ok else 1
195
+
196
+
197
+ def _cmd_deploy(args: argparse.Namespace) -> int:
198
+ result = deploy_file(args.file, args.out)
199
+ if args.json:
200
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
201
+ else:
202
+ print(result.render(), end="")
203
+ return 0 if result.ok else 1
204
+
205
+
206
+ def _resolve_state_dir(explicit: str | None) -> str:
207
+ """Base senpi-state dir for the on-box canonical layout: explicit ``--state-dir`` (the rim
208
+ passes the authoritative resolveSenpiBaseStateDir value) → ``SENPI_STATE_DIR`` env →
209
+ ``~/.openclaw/senpi-state`` default. Mirrors the env+default tail of
210
+ src/cli/senpi-cli-config.ts::resolveSenpiBaseStateDir (the store/plugin-stateDir precedence
211
+ lives in the rim; this Python fallback covers a direct-CLI invocation)."""
212
+ if explicit and explicit.strip():
213
+ return str(Path(explicit).expanduser())
214
+ env = os.environ.get("SENPI_STATE_DIR", "").strip()
215
+ if env:
216
+ return str(Path(env).expanduser())
217
+ return str(Path.home() / ".openclaw" / "senpi-state")
218
+
219
+
220
+ def _cmd_install(args: argparse.Namespace) -> int:
221
+ result = run_install(
222
+ args.target,
223
+ args.wallet,
224
+ _resolve_state_dir(args.state_dir),
225
+ out_dir=args.out,
226
+ runtime_id=args.runtime_id,
227
+ bound_at=args.bound_at,
228
+ )
229
+ if args.json:
230
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
231
+ else:
232
+ print(result.render(), end="")
233
+ return 0 if result.ok else 1
234
+
235
+
236
+ def _cmd_fund(args: argparse.Namespace) -> int:
237
+ result = run_fund(
238
+ args.target,
239
+ args.budget,
240
+ _resolve_state_dir(args.state_dir),
241
+ out_dir=args.out,
242
+ created_at=args.created_at,
243
+ )
244
+ if args.json:
245
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
246
+ else:
247
+ print(result.render(), end="")
248
+ return 0 if result.ok else 1
249
+
250
+
251
+ def _cmd_close(args: argparse.Namespace) -> int:
252
+ result = run_close(
253
+ args.target,
254
+ _resolve_state_dir(args.state_dir),
255
+ wallet=args.wallet,
256
+ strategy_id=args.strategy_id,
257
+ )
258
+ if args.json:
259
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
260
+ else:
261
+ print(result.render(), end="")
262
+ return 0 if result.ok else 1
263
+
264
+
265
+ def _cmd_status(args: argparse.Namespace) -> int:
266
+ # --state-dir opts INTO the on-box install-chain derivation; without it status stops at the
267
+ # deploy layer (the file-only lifecycle), exactly as before.
268
+ state_dir = _resolve_state_dir(args.state_dir) if args.state_dir is not None else None
269
+ result = run_status(args.target, out_dir=args.out, state_dir=state_dir)
270
+ if args.json:
271
+ print(json.dumps(result.to_json(), indent=2, sort_keys=True))
272
+ else:
273
+ print(result.render(), end="")
274
+ return 0
275
+
276
+
277
+ def _cmd_catalog(args: argparse.Namespace) -> int:
278
+ if args.json:
279
+ print(json.dumps({"catalog": catalog_text()}, indent=2, sort_keys=True))
280
+ else:
281
+ print(catalog_text(), end="")
282
+ return 0
283
+
284
+
285
+ def _cmd_describe(args: argparse.Namespace) -> int:
286
+ try:
287
+ text = describe_text(args.node)
288
+ except KeyError as exc:
289
+ msg = str(exc).strip('"')
290
+ if args.json:
291
+ print(json.dumps({"ok": False, "error": msg}, indent=2))
292
+ else:
293
+ print(msg, file=sys.stderr)
294
+ return 1
295
+ if args.json:
296
+ print(json.dumps({"ok": True, "node": args.node, "describe": text}, indent=2, sort_keys=True))
297
+ else:
298
+ print(text, end="")
299
+ return 0
300
+
301
+
302
+ def build_parser() -> argparse.ArgumentParser:
303
+ parser = argparse.ArgumentParser(
304
+ prog="composer",
305
+ description="Strategy Composer — build, validate, and deploy trading strategy graphs.",
306
+ )
307
+ sub = parser.add_subparsers(dest="command", required=True)
308
+
309
+ p_new = sub.add_parser(
310
+ "new",
311
+ help="Generate a COMPLETE, VALID strategy graph from interview answers (D7).",
312
+ )
313
+ p_new.add_argument("id", help="Strategy id; the graph is written to <id>.graph.yaml.")
314
+ p_new.add_argument("--archetype", required=True,
315
+ help="Archetype template (trend_momentum | breakout | classifier | "
316
+ "thesis_fund); an unknown one falls back to a minimal valid skeleton.")
317
+ p_new.add_argument("--answers", help="Path to a YAML/JSON answers-file (flags override it).")
318
+ p_new.add_argument("-o", "--output", help="Output directory (default: cwd).")
319
+ p_new.add_argument("--force", action="store_true",
320
+ help="Overwrite an edited graph (default: refuse to clobber edits).")
321
+ p_new.add_argument("--json", action="store_true",
322
+ help="Emit the structured result (path / gap report) as JSON.")
323
+ # individual flag overrides (D7): each overrides the answers-file field-by-field.
324
+ p_new.add_argument("--whitelist", help="Comma-separated fixed universe (trend_momentum).")
325
+ p_new.add_argument("--long-basket", dest="long_basket", help="Comma-separated LONG leg (thesis_fund).")
326
+ p_new.add_argument("--short-basket", dest="short_basket", help="Comma-separated SHORT leg (thesis_fund).")
327
+ p_new.add_argument("--discover", action="store_true", help="Derive the universe from the live board (classifier).")
328
+ p_new.add_argument("--dex", help="Venue filter for discovery (classifier).")
329
+ p_new.add_argument("--name-prefix", dest="name_prefix", help="Instrument name prefix for discovery (classifier).")
330
+ p_new.add_argument("--max-leverage-cap", dest="max_leverage_cap", type=float, help="Discovery leverage cap (classifier).")
331
+ p_new.add_argument("--min-day-volume", dest="min_day_volume", type=float, help="Discovery daily-volume floor (classifier).")
332
+ p_new.add_argument("--intervals", help="Comma-separated candle intervals (e.g. 1h,4h).")
333
+ p_new.add_argument("--max-positions", dest="max_positions", type=int, help="Cardinality: 1 = single best, >1 = pool-cap.")
334
+ p_new.add_argument("--ttl-seconds", dest="ttl_seconds", type=int, help="Signal-dedup TTL window (memory).")
335
+ p_new.add_argument("--exit-preset", dest="exit_preset",
336
+ help="Exit protection preset (protected_standard | protected_tight | protected_wide); "
337
+ "default protected_standard is bound if unset (protection-by-default).")
338
+ p_new.add_argument("--unmanaged", action="store_true",
339
+ help="Deliberately opt OUT of exit protection (no exit section, loud warning). "
340
+ "Without this, an exit-less unit is RED at check (CMP120).")
341
+ p_new.add_argument("--margin-pct", dest="margin_pct", type=float, help="Percent of withdrawable to allocate, (0,100].")
342
+ p_new.add_argument("--leverage", type=float, help="Requested leverage.")
343
+ p_new.add_argument("--drawdown-halt-pct", dest="drawdown_halt_pct", type=float,
344
+ help="Guard rail: halt the whole strategy at this equity drawdown %% (0-100).")
345
+ p_new.add_argument("--max-consecutive-losses", dest="max_consecutive_losses", type=int,
346
+ help="Guard rail: pause after N consecutive losses.")
347
+ p_new.add_argument("--cooldown-seconds", dest="cooldown_seconds", type=int,
348
+ help="Guard rail: post-loss cooldown seconds (>= 60).")
349
+ p_new.add_argument("--max-entries-per-day", dest="max_entries_per_day", type=int,
350
+ help="Guard rail: cap entries per day (>= 1).")
351
+ p_new.add_argument("--edge", help="The strategy's EDGE, carried verbatim into the graph (never interpreted).")
352
+ p_new.set_defaults(func=_cmd_new)
353
+
354
+ p_validate = sub.add_parser(
355
+ "validate",
356
+ help="Validate a strategy graph (schema + graph checks) with teaching errors.",
357
+ )
358
+ p_validate.add_argument("file", help="Path to the YAML strategy graph.")
359
+ p_validate.add_argument(
360
+ "--json",
361
+ action="store_true",
362
+ help="Emit the structured result as JSON instead of the plain-text report.",
363
+ )
364
+ p_validate.set_defaults(func=_cmd_validate)
365
+
366
+ p_compile = sub.add_parser(
367
+ "compile",
368
+ help="Compile a validated strategy graph into a scan.py on the external_scanner contract.",
369
+ )
370
+ p_compile.add_argument("file", help="Path to the YAML strategy graph.")
371
+ p_compile.add_argument(
372
+ "-o",
373
+ "--output",
374
+ help="Write the emitted scan.py here (default: stdout).",
375
+ )
376
+ p_compile.set_defaults(func=_cmd_compile)
377
+
378
+ p_check = sub.add_parser(
379
+ "check",
380
+ help="ONE verdict: validate + pure_fn tests + compile + fixture smoke (exit 0 iff GREEN).",
381
+ )
382
+ p_check.add_argument("file", help="Path to the YAML strategy graph.")
383
+ p_check.add_argument("--json", action="store_true", help="Emit the verdict as JSON.")
384
+ p_check.set_defaults(func=_cmd_check)
385
+
386
+ p_deploy = sub.add_parser(
387
+ "deploy",
388
+ help="Stage the deployable artifact set (idempotent); HARD-gated on a GREEN check.",
389
+ )
390
+ p_deploy.add_argument("file", help="Path to the YAML strategy graph.")
391
+ p_deploy.add_argument("-o", "--out", default="dist", help="Staging directory (default: dist).")
392
+ p_deploy.add_argument("--json", action="store_true", help="Emit the deploy verdict as JSON.")
393
+ p_deploy.set_defaults(func=_cmd_deploy)
394
+
395
+ p_install = sub.add_parser(
396
+ "install",
397
+ help="Bind a staged unit onto THIS box (copy to a hash dir, bind --wallet, run "
398
+ "`openclaw senpi runtime create`).",
399
+ )
400
+ p_install.add_argument("target", help="A `composer deploy` staging dir, or a graph file (with -o).")
401
+ p_install.add_argument("--wallet",
402
+ help="The strategy's ACTIVE 0x… wallet to bind. Omit to auto-bind the "
403
+ "funded wallet from `composer fund` (its wallet.json), if ACTIVE.")
404
+ p_install.add_argument("-o", "--out", help="Staging dir (when target is a graph file, not a staged dir).")
405
+ p_install.add_argument("--state-dir", dest="state_dir",
406
+ help="Base senpi-state dir (default: $SENPI_STATE_DIR or ~/.openclaw/senpi-state).")
407
+ p_install.add_argument("--runtime-id", dest="runtime_id",
408
+ help="Override the derived runtime id (default: <strategy>-<hash8>).")
409
+ p_install.add_argument("--bound-at", dest="bound_at",
410
+ help="Timestamp recorded in install.json (injected; no wall-clock default).")
411
+ p_install.add_argument("--json", action="store_true", help="Emit the install verdict as JSON.")
412
+ p_install.set_defaults(func=_cmd_install)
413
+
414
+ p_fund = sub.add_parser(
415
+ "fund",
416
+ help="Create + fund the strategy's wallet (MCP strategy_create_custom_strategy): preflights "
417
+ "the balance, waits for ACTIVE, safe to re-run while funding settles.",
418
+ )
419
+ p_fund.add_argument("target", help="A `composer deploy` staging dir, or a graph file (with -o).")
420
+ p_fund.add_argument("--budget", type=float, required=True,
421
+ help="Explicit USD to fund THIS strategy's wallet with (≥ $100). Never "
422
+ "derived or split — agree it with the user first.")
423
+ p_fund.add_argument("-o", "--out", help="Staging dir (when target is a graph file, not a staged dir).")
424
+ p_fund.add_argument("--state-dir", dest="state_dir",
425
+ help="Base senpi-state dir (default: $SENPI_STATE_DIR or ~/.openclaw/senpi-state).")
426
+ p_fund.add_argument("--created-at", dest="created_at",
427
+ help="Timestamp recorded in wallet.json (injected; no wall-clock default).")
428
+ p_fund.add_argument("--json", action="store_true", help="Emit the fund verdict as JSON.")
429
+ p_fund.set_defaults(func=_cmd_fund)
430
+
431
+ p_close = sub.add_parser(
432
+ "close",
433
+ help="Safe teardown: stop the runtime, confirm gone, then trigger MCP strategy_close "
434
+ "(flattens positions + returns funds). Idempotent — re-run to poll.",
435
+ )
436
+ p_close.add_argument("target", help="Strategy name / canonical hash dir / runtime id / 0x… "
437
+ "wallet / strategyId to close.")
438
+ p_close.add_argument("--wallet", help="Close the strategy on this 0x… wallet directly "
439
+ "(overrides target-based wallet resolution).")
440
+ p_close.add_argument("--strategy-id", dest="strategy_id",
441
+ help="Close this strategyId directly (legacy/ops-deployed strategies).")
442
+ p_close.add_argument("--state-dir", dest="state_dir",
443
+ help="Base senpi-state dir (default: $SENPI_STATE_DIR or ~/.openclaw/senpi-state).")
444
+ p_close.add_argument("--json", action="store_true", help="Emit the close verdict as JSON.")
445
+ p_close.set_defaults(func=_cmd_close)
446
+
447
+ p_status = sub.add_parser(
448
+ "status",
449
+ help="Derived lifecycle position + the next verb to run (no journals; state is the file). "
450
+ "Pass --state-dir to extend down the on-box install chain.",
451
+ )
452
+ p_status.add_argument("target", help="A graph YAML file, or a staging directory.")
453
+ p_status.add_argument("-o", "--out", help="Staging directory to check for a deployed artifact.")
454
+ p_status.add_argument("--state-dir", dest="state_dir",
455
+ help="Base senpi-state dir — opts into the on-box install chain "
456
+ "(landed → registered → running). Default when set empty: "
457
+ "$SENPI_STATE_DIR or ~/.openclaw/senpi-state.")
458
+ p_status.add_argument("--json", action="store_true", help="Emit the status as JSON.")
459
+ p_status.set_defaults(func=_cmd_status)
460
+
461
+ p_catalog = sub.add_parser(
462
+ "catalog",
463
+ help="Print the registry-generated catalog index (node names + one-liners + params).",
464
+ )
465
+ p_catalog.add_argument("--json", action="store_true", help="Wrap the catalog text in JSON.")
466
+ p_catalog.set_defaults(func=_cmd_catalog)
467
+
468
+ p_describe = sub.add_parser(
469
+ "describe",
470
+ help="Print one node's full ports/params/example from the registry.",
471
+ )
472
+ p_describe.add_argument("node", help="Node name (see `composer catalog`).")
473
+ p_describe.add_argument("--json", action="store_true", help="Wrap the describe text in JSON.")
474
+ p_describe.set_defaults(func=_cmd_describe)
475
+
476
+ return parser
477
+
478
+
479
+ def main(argv: list[str] | None = None) -> int:
480
+ argv = sys.argv[1:] if argv is None else argv
481
+ parser = build_parser()
482
+ args = parser.parse_args(argv)
483
+ return args.func(args)
484
+
485
+
486
+ if __name__ == "__main__":
487
+ raise SystemExit(main())
@@ -0,0 +1,22 @@
1
+ """compiler — the deterministic materializer: strategy graph -> runtime-owned scan.py.
2
+
3
+ Compilation over interpretation (D5): the compiler validates the graph, topo-sorts it,
4
+ walks per-node Jinja2 emission templates in that order, and produces straight-line Python
5
+ targeting the existing ``external_scanner`` contract — no IR (the graph is the IR), no
6
+ optimizer, no off-the-shelf codegen framework (D8). Semantics are compile-on-deploy with
7
+ freeze-on-deploy (works-forever); a consent-based upgrade recompiles. Determinism is a
8
+ hard property: the same graph must emit byte-identical ``scan.py``. Correctness is tested
9
+ against our declared semantics via self-authored golden vectors (per-primitive known
10
+ series -> known values, plus small composed synthetic cases with hand-computed expected
11
+ signals), NOT by bit-diffing the hand-written corpus originals — corpus fidelity is a
12
+ separate judged, recorded-replay oracle deferred to slice 8. (Implemented in slice 3.)
13
+
14
+ Public API:
15
+ compile_text / compile_file — validate then materialize graph YAML into scan.py source.
16
+ CompileError — invalid graph (surfaces the teaching-error report) or a
17
+ structural gap the slice-3 compiler cannot emit.
18
+ """
19
+
20
+ from .compile import CompileError, compile_file, compile_text
21
+
22
+ __all__ = ["compile_text", "compile_file", "CompileError"]