@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260721003344 → 3.0.9-dev.feat-strategy-compos.20260722213012

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 (46) hide show
  1. package/dist/cli/composer-commands.d.ts.map +1 -1
  2. package/dist/cli/composer-commands.js +193 -41
  3. package/dist/cli/composer-commands.js.map +1 -1
  4. package/dist/cli/composer-live-read.d.ts +68 -0
  5. package/dist/cli/composer-live-read.d.ts.map +1 -0
  6. package/dist/cli/composer-live-read.js +126 -0
  7. package/dist/cli/composer-live-read.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts.map +1 -1
  9. package/dist/cli/senpi-commands.js +8 -10
  10. package/dist/cli/senpi-commands.js.map +1 -1
  11. package/dist/cli/senpi-strategy-tool.d.ts +56 -0
  12. package/dist/cli/senpi-strategy-tool.d.ts.map +1 -0
  13. package/dist/cli/senpi-strategy-tool.js +158 -0
  14. package/dist/cli/senpi-strategy-tool.js.map +1 -0
  15. package/dist/composer/authoring/__init__.py +2 -0
  16. package/dist/composer/authoring/answers.py +52 -0
  17. package/dist/composer/authoring/archetypes/__init__.py +2 -0
  18. package/dist/composer/authoring/archetypes/contrarian_fade.py +493 -0
  19. package/dist/composer/authoring/archetypes/relative_value.py +280 -23
  20. package/dist/composer/cli.py +57 -6
  21. package/dist/composer/examples/contrarian_fade_crowd.graph.yaml +356 -0
  22. package/dist/composer/examples/rotation_cross_sectional.graph.yaml +389 -0
  23. package/dist/composer/generated/catalog.md +2 -1
  24. package/dist/composer/registry/generate.py +2 -1
  25. package/dist/composer/validator/errors.py +2 -0
  26. package/dist/composer/verbs/__init__.py +6 -0
  27. package/dist/composer/verbs/_box.py +96 -5
  28. package/dist/composer/verbs/_layout.py +37 -0
  29. package/dist/composer/verbs/_live.py +50 -6
  30. package/dist/composer/verbs/close.py +51 -6
  31. package/dist/composer/verbs/fund.py +144 -27
  32. package/dist/composer/verbs/install.py +25 -6
  33. package/dist/composer/verbs/portfolio.py +650 -0
  34. package/dist/composer/verbs/review.py +724 -0
  35. package/dist/composer/verbs/status.py +129 -2
  36. package/dist/composer/verbs/update.py +9 -3
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +22 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/runtime/composer-managed-write.d.ts +133 -0
  42. package/dist/runtime/composer-managed-write.d.ts.map +1 -0
  43. package/dist/runtime/composer-managed-write.js +369 -0
  44. package/dist/runtime/composer-managed-write.js.map +1 -0
  45. package/openclaw.plugin.json +5 -0
  46. package/package.json +2 -1
@@ -96,10 +96,15 @@ class LiveState:
96
96
  # --------------------------------------------------------------------------- default seam impl
97
97
 
98
98
  def default_system_state(runtime_id: Optional[str]) -> LiveState:
99
- """Production :data:`SystemStateFn`: shell ``openclaw senpi state --json [-r <id>]`` (RPC
100
- ``senpi.getSystemState``) and parse the one matching runtime's components. NEVER throws — every
101
- failure path degrades to ``LiveState(available=False, reason=...)`` so status stays read-only
102
- and prints an honest unavailable section."""
99
+ """Production :data:`SystemStateFn`. FIRST branch (J9): when the rim handed down a live document
100
+ (``SENPI_LIVE_FILE``), serve the system state from its pre-fetched ``system_state`` section — NO
101
+ openclaw boot. The ``senpi state --json`` shell-out below remains ONLY as the FALLBACK for a
102
+ direct/offline invocation with no live-file. NEVER throws — every failure path degrades to
103
+ ``LiveState(available=False, reason=...)`` so status stays read-only and prints an honest
104
+ unavailable section (CMP259)."""
105
+ doc = _box.load_live_document()
106
+ if doc is not None and isinstance(doc.get("system_state"), dict):
107
+ return _live_system_state(doc["system_state"], runtime_id)
103
108
  args = ["openclaw", "senpi", "state", "--json"]
104
109
  if runtime_id:
105
110
  args += ["-r", runtime_id]
@@ -112,6 +117,25 @@ def default_system_state(runtime_id: Optional[str]) -> LiveState:
112
117
  return parse_system_state(res.stdout, runtime_id)
113
118
 
114
119
 
120
+ def _live_system_state(section: dict, runtime_id: Optional[str]) -> LiveState:
121
+ """Serve a :class:`LiveState` from the rim-served document's ``system_state`` section (J9). The
122
+ rim fetches ``senpi.getSystemState`` UNSCOPED (all runtimes) into one document; this scopes it to
123
+ ``runtime_id`` EXACTLY as the gateway's ``-r`` would have — filtering the states to the matching
124
+ runtime and reusing :func:`parse_system_state` UNCHANGED. A ``-r`` miss yields an empty envelope,
125
+ so parse_system_state returns the identical "runtime is not running" degrade the scoped shell
126
+ would. An ``ok:false`` section (the rim could not reach the gateway) stays exactly as LOUD as the
127
+ shell path's failure (CMP259) — never a silent partial."""
128
+ if not section.get("ok"):
129
+ reason = section.get("reason") or "gateway unreachable or errored"
130
+ return LiveState(False, reason=reason, runtime_id=runtime_id)
131
+ states = section.get("states")
132
+ states = states if isinstance(states, list) else []
133
+ if runtime_id:
134
+ states = [s for s in states if isinstance(s, dict)
135
+ and (s.get("runtimeId") == runtime_id or s.get("runtimeName") == runtime_id)]
136
+ return parse_system_state(json.dumps({"ok": True, "states": states}), runtime_id)
137
+
138
+
115
139
  def parse_system_state(stdout: str, runtime_id: Optional[str]) -> LiveState:
116
140
  """Parse the ``senpi state --json`` envelope (``{ok, states:[RuntimeSystemState...]}``) into a
117
141
  :class:`LiveState`. Tolerant: a non-JSON body, an ok:false error, or zero matching states all
@@ -244,6 +268,23 @@ def human_tier(tier_index, tier_count) -> str:
244
268
  return f"tier {tier_index + 1} of {n}"
245
269
 
246
270
 
271
+ def is_tier_armed(p: dict) -> bool:
272
+ """Whether a live position has an ARMED profit-ratchet tier — derived DIRECTLY from the engine's
273
+ ``currentTierIndex`` (0-based, in range against ``tiers``), never from a single fail-open
274
+ ``phase`` field or a separate ratchet-list lookup.
275
+
276
+ This is the field-log incident's fix. A sibling engine (senpi-portfolio's portfolio.py) decided
277
+ "armed" from the presence of a matched ``ratchet_stop_list`` row keyed by asset, whose container
278
+ it parsed under ``configs/ratchetStops/data/items`` — but the live API returns the rows under
279
+ ``positions``, so the lookup silently missed and EVERY armed position (a real ``currentTierIndex``
280
+ of 1 with an active SL order id) was downgraded to "not yet armed / hard_stop null". The box
281
+ engine's ``getSystemState`` already carries ``currentTierIndex`` on each position, so we read the
282
+ armed state off it and never reconstruct it from a second, mis-parsed source."""
283
+ ti = p.get("currentTierIndex")
284
+ tiers = p.get("tiers") or []
285
+ return isinstance(ti, int) and 0 <= ti < len(tiers)
286
+
287
+
247
288
  # --------------------------------------------------------------------------- scanner rendering
248
289
 
249
290
  def _scanner_role(scanner_id: str) -> str:
@@ -427,9 +468,12 @@ def render_protection_lines(p: dict) -> list[str]:
427
468
  f"high-water {_price(hw)} = peak {_pct(hw_roe, signed=True)})"
428
469
  ]
429
470
 
430
- # Phase + active tier, in 1-based human terms.
471
+ # Phase + active tier, in 1-based human terms. ARMED is derived from currentTierIndex DIRECTLY
472
+ # (is_tier_armed) — an armed tier IS phase 2 by engine definition, so we never let a stale/absent
473
+ # ``phase`` field downgrade a genuinely-armed position to the phase-1 "no tier armed" rendering
474
+ # (the field-log incident: currentTierIndex=1 + an active SL order rendered "not yet armed").
431
475
  tier_human = human_tier(tier_idx, len(tiers))
432
- if phase == 2 and isinstance(tier_idx, int) and tier_idx >= 0 and tier_idx < len(tiers):
476
+ if is_tier_armed(p):
433
477
  tier = tiers[tier_idx] if isinstance(tiers[tier_idx], dict) else {}
434
478
  lock_hw = tier.get("lockHwPct")
435
479
  locked = p.get("lockedProfitPct")
@@ -56,7 +56,14 @@ from ._box import (
56
56
  McpFn,
57
57
  wallet_match,
58
58
  )
59
- from ._layout import COMPOSER_DIR, LEGACY_STRATEGIES_DIR, iter_release_dirs
59
+ from ._layout import (
60
+ COMPOSER_DIR,
61
+ KIND_MANUAL,
62
+ LEGACY_STRATEGIES_DIR,
63
+ iter_release_dirs,
64
+ read_strategy_kind,
65
+ resolve_wallet_json,
66
+ )
60
67
  from .install import INSTALL_JSON
61
68
 
62
69
  CLOSING = "CLOSING"
@@ -366,6 +373,32 @@ def _resolve_targets(target: str, base_state_dir: str, strategies: list,
366
373
  ))
367
374
  return out
368
375
 
376
+ # (manual) J3: a MANUAL strategy owns a canonical wallet.json marked ``kind: manual`` but NO
377
+ # install.json artifact (it has no runtime and was never installed). Resolve it FROM that record
378
+ # — wallet + backend strategyId — so close discovers it directly (not reliant on MCP name-match)
379
+ # and reports source="manual". The teardown is naturally REDUCED: _close_one finds no runtime to
380
+ # stop (there is none) and submits strategy_close, which flattens any positions + returns funds —
381
+ # exactly the right and sanctioned teardown for a manual strategy (no runtime-delete step).
382
+ if read_strategy_kind(base_state_dir, target) == KIND_MANUAL:
383
+ wj_path, _layout = resolve_wallet_json(base_state_dir, target)
384
+ rec: dict = {}
385
+ if wj_path is not None:
386
+ try:
387
+ rec = json.loads(wj_path.read_text(encoding="utf-8"))
388
+ except (OSError, json.JSONDecodeError):
389
+ rec = {}
390
+ wallet = str(rec.get("wallet") or "")
391
+ rec_sid = rec.get("strategyId")
392
+ s = next((s for s in strategies
393
+ if (rec_sid and _strat_id(s) == rec_sid) or wallet_match(_strat_wallet(s), wallet)),
394
+ None)
395
+ return [CloseTarget(
396
+ wallet=wallet, source="manual",
397
+ strategy_id=rec_sid or (_strat_id(s) if s else None),
398
+ status=str(_strat_status(s) or "").upper() if s else None,
399
+ label=f"{target}:manual",
400
+ )]
401
+
369
402
  # (legacy) match the strategy record by strategyId, by OLD-shape skillName attribution, OR by
370
403
  # NEW-shape (H4) strategyName. The three legs together discover BOTH strategies already live in
371
404
  # the field (skillName == the strategy name) and composer-minted ones (skillName ==
@@ -407,8 +440,12 @@ def _live_runtime_for(runtimes: list, wallet: str, runtime_id: Optional[str]):
407
440
 
408
441
 
409
442
  def _close_one(t: CloseTarget, list_fn: ListFn, delete_fn: DeleteFn,
410
- mcp_fn: McpFn, mcp_ok: bool) -> InstanceResult:
411
- """Enforce the invariant for ONE strategy: STOP runtime → CONFIRM gone → TRIGGER close."""
443
+ mcp_fn: McpFn, mcp_ok: bool, confirm_list_fn: ListFn) -> InstanceResult:
444
+ """Enforce the invariant for ONE strategy: STOP runtime → CONFIRM gone → TRIGGER close.
445
+
446
+ ``list_fn`` supplies the DISCOVERY read (may come from a rim-served snapshot, J9); ``confirm_list_fn``
447
+ is the post-delete CONFIRM re-read, which MUST be a FRESH read — never a pre-run snapshot that
448
+ would falsely see the just-deleted runtime as still live (CMP221)."""
412
449
  rec = InstanceResult(label=t.label or (t.wallet[:14] + "…"), wallet=t.wallet,
413
450
  verdict=CLOSING, source=t.source, strategy_id=t.strategy_id,
414
451
  runtime_id=t.runtime_id, strategy_status=t.status)
@@ -432,8 +469,9 @@ def _close_one(t: CloseTarget, list_fn: ListFn, delete_fn: DeleteFn,
432
469
  Location(),
433
470
  )
434
471
  return rec
435
- # CONFIRM gone — re-list; the invariant forbids strategy_close while the delete is unconfirmed.
436
- if _live_runtime_for(list_fn(), t.wallet, t.runtime_id) is not None:
472
+ # CONFIRM gone — re-list FRESH; the invariant forbids strategy_close while the delete is
473
+ # unconfirmed (a rim-served snapshot predates the delete, so the confirm never reads it — J9).
474
+ if _live_runtime_for(confirm_list_fn(), t.wallet, t.runtime_id) is not None:
437
475
  rec.verdict = FAILED
438
476
  rec.runtime = "still_present"
439
477
  rec.error = TeachingError(
@@ -545,7 +583,14 @@ def run_close(
545
583
  Location(),
546
584
  ))
547
585
 
548
- instances = [_close_one(t, list_fn, delete_fn, mcp_fn, mcp_ok) for t in targets]
586
+ # The post-delete CONFIRM must be a FRESH read. When the PRODUCTION default seam is in play, the
587
+ # discovery ``list_fn`` may serve from a rim-served snapshot (J9), so the confirm shells fresh via
588
+ # ``list_runtimes_fresh``; an INJECTED seam (tests) serves both — its identity is not the default,
589
+ # so the confirm uses it unchanged (a stateful fake reflects the delete exactly as before).
590
+ confirm_list_fn: ListFn = (
591
+ _box.list_runtimes_fresh if list_fn is _box.default_list_runtimes else list_fn
592
+ )
593
+ instances = [_close_one(t, list_fn, delete_fn, mcp_fn, mcp_ok, confirm_list_fn) for t in targets]
549
594
  verdicts = {i.verdict for i in instances}
550
595
  overall = (FAILED if FAILED in verdicts else
551
596
  CLOSING if CLOSING in verdicts else CLOSED)
@@ -67,7 +67,16 @@ from ._box import (
67
67
  McpFn,
68
68
  wallet_match,
69
69
  )
70
- from ._layout import canonical_wallet_json, resolve_wallet_json
70
+ from ._layout import (
71
+ KIND_MANAGED,
72
+ KIND_MANUAL,
73
+ canonical_graph_path,
74
+ canonical_releases_dir,
75
+ canonical_staged_dir,
76
+ canonical_wallet_json,
77
+ resolve_wallet_json,
78
+ wallet_kind,
79
+ )
71
80
  from .close import (
72
81
  _dig,
73
82
  _find_list,
@@ -197,10 +206,15 @@ def _read_wallet_json(path: Path) -> Optional[dict]:
197
206
 
198
207
 
199
208
  def _write_wallet_json(path: Path, *, strategy: str, wallet: str, strategy_id: str,
200
- budget: float, created_at: Optional[str]) -> None:
209
+ budget: float, created_at: Optional[str],
210
+ kind: Optional[str] = None) -> None:
201
211
  """Record the minted wallet BESIDE the strategy (the resumable-by-artifact marker). Written
202
212
  BEFORE polling so an interrupted run resumes at step (a). ``created_at`` is an INJECTED value
203
- (no wall-clock in the Python core) — None when the caller omits it."""
213
+ (no wall-clock in the Python core) — None when the caller omits it.
214
+
215
+ ``kind`` (J3) stamps the strategy KIND marker. It is OMITTED entirely for a MANAGED wallet
216
+ (``kind`` None or ``"managed"``) so the managed payload is byte-for-byte what it was pre-J3;
217
+ only a MANUAL wallet carries ``"kind": "manual"`` (absence = managed, back-compat)."""
204
218
  path.parent.mkdir(parents=True, exist_ok=True)
205
219
  payload = {
206
220
  "strategy": strategy,
@@ -209,6 +223,8 @@ def _write_wallet_json(path: Path, *, strategy: str, wallet: str, strategy_id: s
209
223
  "budget": budget,
210
224
  "created_at": created_at,
211
225
  }
226
+ if kind == KIND_MANUAL:
227
+ payload["kind"] = KIND_MANUAL
212
228
  path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
213
229
 
214
230
 
@@ -237,6 +253,7 @@ class FundResult:
237
253
  strategy_id: Optional[str] = None
238
254
  budget: Optional[float] = None
239
255
  available: Optional[float] = None
256
+ kind: str = KIND_MANAGED # J3: "managed" (a graph strategy) | "manual" (wallet, no runtime)
240
257
  pre_action: str = "" # the unmistakable money-mutation line (create path only)
241
258
  errors: list[TeachingError] = field(default_factory=list)
242
259
  message: str = ""
@@ -255,6 +272,7 @@ class FundResult:
255
272
  "strategy_id": self.strategy_id,
256
273
  "budget": self.budget,
257
274
  "available": self.available,
275
+ "kind": self.kind,
258
276
  "pre_action": self.pre_action,
259
277
  "error_count": len(self.errors),
260
278
  "errors": [e.to_dict() for e in self.errors],
@@ -267,6 +285,8 @@ class FundResult:
267
285
  lines.append(f" {self.pre_action}")
268
286
  if self.strategy:
269
287
  lines.append(f" strategy : {self.strategy}")
288
+ if self.kind == KIND_MANUAL:
289
+ lines.append(" kind : manual (wallet only — NO graph/runtime)")
270
290
  if self.wallet:
271
291
  lines.append(f" wallet : {self.wallet}")
272
292
  if self.strategy_id:
@@ -275,7 +295,11 @@ class FundResult:
275
295
  lines.append(f" budget : ${self.budget:,.2f}")
276
296
  if self.message:
277
297
  lines.append(f" {self.message}")
278
- if self.verdict == FUNDED and self.wallet:
298
+ if self.verdict == FUNDED and self.wallet and self.kind == KIND_MANUAL:
299
+ lines.append(" next: manual strategy is live — place/manage positions directly via "
300
+ "MCP on this wallet (the sanctioned path for this kind). `composer status "
301
+ f"{self.strategy or '<name>'}` shows it; `composer close` tears it down.")
302
+ elif self.verdict == FUNDED and self.wallet:
279
303
  lines.append(f" next: `composer install <target>` (auto-binds this wallet), or "
280
304
  f"`--wallet {self.wallet}`.")
281
305
  if self.verdict == PENDING:
@@ -300,12 +324,14 @@ def run_fund(
300
324
  *,
301
325
  out_dir: Optional[str] = None,
302
326
  created_at: Optional[str] = None,
327
+ manual: bool = False,
303
328
  mcp_fn: McpFn = _box.default_mcp_call,
304
329
  sleep_fn: Callable[[float], None] = time.sleep,
305
330
  poll_attempts: int = 3,
306
331
  poll_interval: float = 5.0,
307
332
  ) -> FundResult:
308
- """Create + fund a strategy's wallet (see module docstring)."""
333
+ """Create + fund a strategy's wallet (see module docstring). ``manual=True`` (J3) funds a MANUAL
334
+ strategy — a wallet with NO graph/staged unit — resolving the target as a bare strategy name."""
309
335
  # (0) --budget gate — REQUIRED, EXPLICIT, ≥ the platform floor. Never derived or split.
310
336
  if budget is None or _num(budget) is None or float(budget) < MIN_BUDGET:
311
337
  shown = "(missing)" if budget is None else f"${float(budget):,.2f}" if _num(budget) is not None else repr(budget)
@@ -319,6 +345,14 @@ def run_fund(
319
345
  ), budget=_num(budget))
320
346
  budget = float(budget)
321
347
 
348
+ # J3: a MANUAL strategy has NO graph / no staged unit — it is a funded wallet under composer's
349
+ # record system, nothing more. It resolves by NAME (the target IS the strategy) and refuses to
350
+ # collide with a managed strategy. The managed path below is byte-for-byte unchanged.
351
+ if manual:
352
+ return _run_fund_manual(target, budget, base_state_dir, mcp_fn=mcp_fn,
353
+ created_at=created_at, sleep_fn=sleep_fn,
354
+ poll_attempts=poll_attempts, poll_interval=poll_interval)
355
+
322
356
  # (1) resolve the staged unit → strategy name (+ version for attribution). Bare strategy names
323
357
  # and canonical graph paths resolve through the layout (G8b).
324
358
  staged = _resolve_staged(target, out_dir, base_state_dir)
@@ -326,7 +360,8 @@ def run_fund(
326
360
  return _refused(target, "", TeachingError(
327
361
  "CMP200", "the fund target is not a staged unit (no manifest.json found).",
328
362
  "Point `composer fund` at a `composer deploy` staging dir, or pass the graph file plus "
329
- "`-o <staging-dir>`. Run `composer deploy <graph> -o <dir>` first.",
363
+ "`-o <staging-dir>`. Run `composer deploy <graph> -o <dir>` first. (To fund a MANUAL "
364
+ "strategy — a wallet with no graph — use `composer fund <name> --manual --budget N`.)",
330
365
  Location(),
331
366
  ), budget=budget)
332
367
  try:
@@ -344,11 +379,82 @@ def run_fund(
344
379
  return _verify_existing(target, strategy, budget, existing, existing_path, mcp_fn, created_at)
345
380
  wj_path = wallet_json_path(base_state_dir, strategy) # canonical write path for a fresh mint
346
381
 
382
+ # (b)+(c) preflight balance → create → record → poll (shared with the manual path; kind=None so
383
+ # the managed wallet.json payload is byte-unchanged).
384
+ return _mint_wallet(target, strategy, budget, wj_path, mcp_fn=mcp_fn, created_at=created_at,
385
+ sleep_fn=sleep_fn, poll_attempts=poll_attempts, poll_interval=poll_interval,
386
+ kind=None)
387
+
388
+
389
+ # --------------------------------------------------------------------------- manual strategy (J3)
390
+
391
+ def _manual_artifacts_present(base_state_dir: str, strategy: str) -> bool:
392
+ """True when a MANAGED strategy already owns ``strategy``'s canonical dir — a graph.yaml record,
393
+ a staged/ deploy output, or a releases/ install tree. These are the graph-lifecycle artifacts a
394
+ manual strategy NEVER has, so their presence is the collision signal (CMP262)."""
395
+ return (
396
+ canonical_graph_path(base_state_dir, strategy).is_file()
397
+ or canonical_staged_dir(base_state_dir, strategy).is_dir()
398
+ or canonical_releases_dir(base_state_dir, strategy).is_dir()
399
+ )
400
+
401
+
402
+ def _run_fund_manual(target: str, budget: float, base_state_dir: str, *,
403
+ mcp_fn: McpFn, created_at: Optional[str],
404
+ sleep_fn: Callable[[float], None], poll_attempts: int,
405
+ poll_interval: float) -> FundResult:
406
+ """Fund a MANUAL strategy (J3): the target IS the strategy name; there is no graph to resolve.
407
+ Refuses (CMP262) if the name already belongs to a MANAGED strategy; otherwise mints + funds a
408
+ wallet exactly like the managed path, stamping the ``kind: manual`` marker into wallet.json."""
409
+ strategy = str(target)
410
+
411
+ existing_path, _layout = resolve_wallet_json(base_state_dir, strategy)
412
+ existing = _read_wallet_json(existing_path) if existing_path is not None else None
413
+ managed_wallet = existing is not None and wallet_kind(existing) != KIND_MANUAL
414
+
415
+ # COLLISION: never overwrite a managed strategy (graph/staged/releases, or a managed wallet.json).
416
+ if _manual_artifacts_present(base_state_dir, strategy) or managed_wallet:
417
+ return _refused(target, strategy, TeachingError(
418
+ "CMP262", f"{strategy!r} already exists as a MANAGED strategy (it has a graph / staged "
419
+ f"/ installed artifacts or a managed wallet.json) — it cannot be re-created as "
420
+ f"a manual wallet.",
421
+ f"Pick a DIFFERENT name for the manual wallet, or manage {strategy!r} as the graph "
422
+ f"strategy it already is (`composer status {strategy}` shows its lifecycle; "
423
+ f"`composer fund {strategy} --budget N` funds its graph-bound wallet). `--manual` only "
424
+ f"creates a brand-new wallet-only strategy.",
425
+ Location(),
426
+ ), budget=budget, kind=KIND_MANUAL)
427
+
428
+ # A pre-existing MANUAL wallet.json → the idempotent verify path (never re-fund).
429
+ if existing is not None:
430
+ res = _verify_existing(target, strategy, budget, existing, existing_path, mcp_fn, created_at,
431
+ kind=KIND_MANUAL)
432
+ res.kind = KIND_MANUAL
433
+ return res
434
+
435
+ wj_path = wallet_json_path(base_state_dir, strategy) # canonical write path for a fresh mint
436
+ return _mint_wallet(target, strategy, budget, wj_path, mcp_fn=mcp_fn, created_at=created_at,
437
+ sleep_fn=sleep_fn, poll_attempts=poll_attempts, poll_interval=poll_interval,
438
+ kind=KIND_MANUAL)
439
+
440
+
441
+ # --------------------------------------------------------------------------- mint (shared b+c)
442
+
443
+ def _mint_wallet(target: str, strategy: str, budget: float, wj_path: Path, *,
444
+ mcp_fn: McpFn, created_at: Optional[str],
445
+ sleep_fn: Callable[[float], None], poll_attempts: int, poll_interval: float,
446
+ kind: Optional[str]) -> FundResult:
447
+ """Steps (b)+(c) shared by the managed + manual fund paths: preflight the visible balance,
448
+ create the wallet via MCP (a money mutation), record wallet.json BEFORE polling, then poll
449
+ strategy_list briefly for ACTIVE. ``kind`` stamps the wallet.json marker (manual only) and is
450
+ carried onto every FundResult so the caller/render/JSON knows the strategy kind."""
451
+ result_kind = kind or KIND_MANAGED
452
+
347
453
  # (b) PREFLIGHT the visible balance (no wallet yet).
348
454
  try:
349
455
  portfolio = mcp_fn("account_get_portfolio", timeout=_box._SUBMIT_TIMEOUT)
350
456
  except McpCredsMissing as e:
351
- return _refused(target, strategy, _creds_error(e), budget=budget)
457
+ return _refused(target, strategy, _creds_error(e), budget=budget, kind=result_kind)
352
458
  except McpError:
353
459
  portfolio = None # transport hiccup — proceed unverified (create fails loud if truly short)
354
460
  available = _available_usd(portfolio)
@@ -363,16 +469,19 @@ def run_fund(
363
469
  "topping up more. To add funds: deposit USDC to your embedded wallet, or bridge from "
364
470
  "Hyperliquid. The composer NEVER bridges or splits the budget for you.",
365
471
  Location(),
366
- ), budget=budget, available=available)
472
+ ), budget=budget, available=available, kind=result_kind)
367
473
 
368
474
  # (c) CREATE — a money mutation. State exactly what is being done (no --yes; conversational).
369
475
  sname = sanitize_strategy_name(strategy)
476
+ manual_note = (" MANUAL strategy (a funded wallet with NO graph/runtime — direct MCP position "
477
+ "edits are the sanctioned path)." if kind == KIND_MANUAL else "")
370
478
  pre_action = (f"CREATING WALLET — strategy={strategy!r}, budget=${budget:,.2f}, "
371
479
  f"funding source=your embedded balance (Hyperliquid + EVM), "
372
480
  f"attribution skillName={COMPOSER_SKILL_NAME!r} (v{COMPOSER_SKILL_VERSION}), "
373
481
  f"strategyName={sname!r}."
374
482
  + ("" if available is not None
375
- else " (balance could not be pre-verified; the backend will reject if short.)"))
483
+ else " (balance could not be pre-verified; the backend will reject if short.)")
484
+ + manual_note)
376
485
 
377
486
  def _create(with_name: bool):
378
487
  # skillName = the AUTHORING skill (never the strategy); skillVersion = the composer version
@@ -397,26 +506,28 @@ def run_fund(
397
506
  res = _create(with_name=True)
398
507
  except McpCredsMissing as e:
399
508
  return _refused(target, strategy, _creds_error(e), budget=budget, available=available,
400
- pre_action=pre_action)
509
+ pre_action=pre_action, kind=result_kind)
401
510
  except McpError as e:
402
511
  msg = str(e)
403
512
  if any(c in msg for c in _NAME_ERR_CODES) or "name" in msg.lower():
404
513
  try:
405
514
  res = _create(with_name=False) # naming is best-effort — retry unnamed
406
515
  except McpError as e2:
407
- return _create_failed(target, strategy, budget, available, pre_action, e2)
516
+ return _create_failed(target, strategy, budget, available, pre_action, e2,
517
+ kind=result_kind)
408
518
  else:
409
- return _create_failed(target, strategy, budget, available, pre_action, e)
519
+ return _create_failed(target, strategy, budget, available, pre_action, e,
520
+ kind=result_kind)
410
521
 
411
522
  sid = _strat_id(res)
412
523
  if not sid:
413
524
  return _create_failed(target, strategy, budget, available, pre_action,
414
- f"create returned no strategyId: {res!r}")
525
+ f"create returned no strategyId: {res!r}", kind=result_kind)
415
526
  addr = str(_strat_wallet(res) or "")
416
527
 
417
528
  # Record BEFORE polling — an interrupted run resumes at step (a), never double-funds.
418
529
  _write_wallet_json(wj_path, strategy=strategy, wallet=addr, strategy_id=sid,
419
- budget=budget, created_at=created_at)
530
+ budget=budget, created_at=created_at, kind=kind)
420
531
 
421
532
  # Poll strategy_list BRIEFLY for ACTIVE (bounded; still pending → PENDING, not a failure).
422
533
  for attempt in range(max(1, poll_attempts)):
@@ -429,10 +540,11 @@ def run_fund(
429
540
  cur_addr = str(_strat_wallet(s) or "") if s else ""
430
541
  if status == "ACTIVE" and cur_addr:
431
542
  _write_wallet_json(wj_path, strategy=strategy, wallet=cur_addr, strategy_id=sid,
432
- budget=budget, created_at=created_at)
543
+ budget=budget, created_at=created_at, kind=kind)
433
544
  return FundResult(
434
545
  target=target, verdict=FUNDED, strategy=strategy, wallet=cur_addr,
435
- strategy_id=sid, budget=budget, available=available, pre_action=pre_action,
546
+ strategy_id=sid, budget=budget, available=available, kind=result_kind,
547
+ pre_action=pre_action,
436
548
  message=f"wallet ACTIVE and funded with ${budget:,.2f}.",
437
549
  )
438
550
  if attempt < poll_attempts - 1:
@@ -440,7 +552,7 @@ def run_fund(
440
552
 
441
553
  return FundResult(
442
554
  target=target, verdict=PENDING, strategy=strategy, wallet=addr, strategy_id=sid,
443
- budget=budget, available=available, pre_action=pre_action,
555
+ budget=budget, available=available, kind=result_kind, pre_action=pre_action,
444
556
  message=f"wallet created (strategyId {sid}) and funding; not ACTIVE yet.",
445
557
  )
446
558
 
@@ -448,8 +560,13 @@ def run_fund(
448
560
  # --------------------------------------------------------------------------- sub-flows
449
561
 
450
562
  def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_path: Path,
451
- mcp_fn: McpFn, created_at: Optional[str]) -> FundResult:
452
- """Step (a): a wallet.json already exists — verify it via strategy_list, never re-fund."""
563
+ mcp_fn: McpFn, created_at: Optional[str],
564
+ kind: Optional[str] = None) -> FundResult:
565
+ """Step (a): a wallet.json already exists — verify it via strategy_list, never re-fund.
566
+ ``kind`` (the recorded marker) is PRESERVED across any in-place backfill so a manual wallet
567
+ never loses its marker on a re-run."""
568
+ rec_kind = kind if kind is not None else wallet_kind(rec)
569
+ result_kind = rec_kind or KIND_MANAGED
453
570
  rec_wallet = str(rec.get("wallet") or "")
454
571
  rec_sid = rec.get("strategyId")
455
572
  try:
@@ -457,7 +574,7 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
457
574
  mcp_ok = True
458
575
  except McpCredsMissing as e:
459
576
  return _refused(target, strategy, _creds_error(e), wallet=rec_wallet,
460
- strategy_id=rec_sid, budget=budget)
577
+ strategy_id=rec_sid, budget=budget, kind=result_kind)
461
578
  except McpError:
462
579
  strategies, mcp_ok = [], False
463
580
 
@@ -470,23 +587,23 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
470
587
  if addr and addr != rec_wallet:
471
588
  _write_wallet_json(wj_path, strategy=strategy, wallet=addr,
472
589
  strategy_id=rec_sid or _strat_id(s), budget=budget,
473
- created_at=rec.get("created_at") or created_at)
590
+ created_at=rec.get("created_at") or created_at, kind=rec_kind)
474
591
  return FundResult(
475
592
  target=target, verdict=FUNDED, strategy=strategy, wallet=addr or rec_wallet,
476
593
  strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
477
- message="wallet already funded and ACTIVE (no-op).",
594
+ kind=result_kind, message="wallet already funded and ACTIVE (no-op).",
478
595
  )
479
596
  if s is not None and status and status not in CLOSED_STATUSES:
480
597
  return FundResult(
481
598
  target=target, verdict=PENDING, strategy=strategy, wallet=addr or rec_wallet,
482
599
  strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
483
- message=f"wallet still initializing (status {status}).",
600
+ kind=result_kind, message=f"wallet still initializing (status {status}).",
484
601
  )
485
602
  if not mcp_ok:
486
603
  # Could not reach MCP to confirm — do NOT declare stale; re-run once reachable.
487
604
  return FundResult(
488
605
  target=target, verdict=PENDING, strategy=strategy, wallet=rec_wallet,
489
- strategy_id=rec_sid, budget=rec.get("budget", budget),
606
+ strategy_id=rec_sid, budget=rec.get("budget", budget), kind=result_kind,
490
607
  message="could not reach MCP to verify the recorded wallet — re-run `composer fund`.",
491
608
  )
492
609
  # Found closed, or not present in the live set at all → the recorded wallet is stale.
@@ -497,7 +614,7 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
497
614
  f"for {strategy!r}, delete the stale marker `{wj_path}` and re-run `composer fund`. If you "
498
615
  f"believe it is still open, check MCP `strategy_list` (it may be filtered out as terminal).",
499
616
  Location(),
500
- ), wallet=rec_wallet, strategy_id=rec_sid, budget=budget)
617
+ ), wallet=rec_wallet, strategy_id=rec_sid, budget=budget, kind=result_kind)
501
618
 
502
619
 
503
620
  def _creds_error(exc: Exception) -> TeachingError:
@@ -510,10 +627,10 @@ def _creds_error(exc: Exception) -> TeachingError:
510
627
 
511
628
 
512
629
  def _create_failed(target: str, strategy: str, budget: float, available: Optional[float],
513
- pre_action: str, err) -> FundResult:
630
+ pre_action: str, err, kind: str = KIND_MANAGED) -> FundResult:
514
631
  return FundResult(
515
632
  target=target, verdict=FAILED, strategy=strategy, budget=budget, available=available,
516
- pre_action=pre_action,
633
+ kind=kind, pre_action=pre_action,
517
634
  errors=[TeachingError(
518
635
  "CMP233", f"MCP `strategy_create_custom_strategy` failed: {str(err)[:200]}.",
519
636
  "No wallet was created (nothing to clean up). Re-run `composer fund` — it is safe "
@@ -104,16 +104,35 @@ class CreateResult:
104
104
  CreateFn = Callable[[str, str], CreateResult]
105
105
 
106
106
 
107
+ # The one production shell-out that had NO timeout: a hung `runtime create` is the field-reported
108
+ # "deploy verify hang" class. Bound it generously (create legitimately runs 30-60s: MCP connect,
109
+ # strategy + DSL init, supervisor + scaffold spawn — senpi-commands.ts uses a 60s gateway timeout),
110
+ # but never UNBOUNDED. 120s leaves ample headroom over the gateway's own 60s while still failing loud.
111
+ _CREATE_TIMEOUT = 120
112
+
113
+
107
114
  def _default_create(runtime_yaml_path: str, runtime_id: str) -> CreateResult:
108
115
  """Production seam: shell out to the FROZEN consumer in PATH mode (never --content — the
109
116
  external scanner needs the recipe dir the -p path carries; index.ts:1088, senpi-commands.ts:94).
110
- Captures stdout/stderr so the verdict is parsed truthfully rather than inherited to the tty."""
117
+ Captures stdout/stderr so the verdict is parsed truthfully rather than inherited to the tty. A
118
+ hang is bounded at ``_CREATE_TIMEOUT`` (J9): a timeout returns an honest non-zero CreateResult
119
+ (mirroring ``_box._run_cli``'s timeout shape) so the mapper teaches it rather than hanging."""
111
120
  openclaw = os.environ.get("OPENCLAW_BIN", "openclaw")
112
- proc = subprocess.run( # noqa: S603 — fixed argv, no shell
113
- [openclaw, "senpi", "runtime", "create", "-p", runtime_yaml_path, "--runtime-id", runtime_id],
114
- capture_output=True,
115
- text=True,
116
- )
121
+ argv = [openclaw, "senpi", "runtime", "create", "-p", runtime_yaml_path, "--runtime-id", runtime_id]
122
+ try:
123
+ proc = subprocess.run( # noqa: S603 — fixed argv, no shell
124
+ argv,
125
+ capture_output=True,
126
+ text=True,
127
+ timeout=_CREATE_TIMEOUT,
128
+ )
129
+ except subprocess.TimeoutExpired:
130
+ return CreateResult(
131
+ -1, "",
132
+ f"`openclaw senpi runtime create` timed out after {_CREATE_TIMEOUT}s "
133
+ f"(runtime {runtime_id!r}). The box may be wedged or the gateway unreachable — re-run "
134
+ f"`composer install` (idempotent); if it persists, check `openclaw senpi runtime list`.",
135
+ )
117
136
  return CreateResult(proc.returncode, proc.stdout or "", proc.stderr or "")
118
137
 
119
138