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

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 (94) 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 +287 -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 +124 -0
  9. package/dist/composer/AUTHORING.md +233 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +49 -0
  12. package/dist/composer/authoring/answers.py +226 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +25 -0
  14. package/dist/composer/authoring/archetypes/_common.py +218 -0
  15. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  16. package/dist/composer/authoring/archetypes/skeleton.py +54 -0
  17. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  18. package/dist/composer/authoring/archetypes/trend_momentum.py +104 -0
  19. package/dist/composer/authoring/builder.py +191 -0
  20. package/dist/composer/authoring/emit.py +264 -0
  21. package/dist/composer/authoring/gaps.py +115 -0
  22. package/dist/composer/authoring/generate.py +131 -0
  23. package/dist/composer/authoring/presets.py +168 -0
  24. package/dist/composer/authoring/runtime_emit.py +266 -0
  25. package/dist/composer/authoring/strategy_manifest.py +94 -0
  26. package/dist/composer/cli.py +355 -0
  27. package/dist/composer/compiler/__init__.py +22 -0
  28. package/dist/composer/compiler/compile.py +550 -0
  29. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  30. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  31. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  32. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  33. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  34. package/dist/composer/generated/README.md +22 -0
  35. package/dist/composer/generated/catalog.md +84 -0
  36. package/dist/composer/generated/describe.md +703 -0
  37. package/dist/composer/generated/graph.schema.json +1203 -0
  38. package/dist/composer/primitives/__init__.py +239 -0
  39. package/dist/composer/primitives/_candles.py +76 -0
  40. package/dist/composer/primitives/adapter.py +120 -0
  41. package/dist/composer/primitives/emitters.py +142 -0
  42. package/dist/composer/primitives/filters.py +71 -0
  43. package/dist/composer/primitives/indicators.py +203 -0
  44. package/dist/composer/primitives/rankers.py +40 -0
  45. package/dist/composer/primitives/scorers.py +100 -0
  46. package/dist/composer/primitives/sources.py +405 -0
  47. package/dist/composer/primitives/stateful.py +52 -0
  48. package/dist/composer/pure_fn/__init__.py +34 -0
  49. package/dist/composer/pure_fn/checks.py +39 -0
  50. package/dist/composer/pure_fn/models.py +177 -0
  51. package/dist/composer/pure_fn/purity.py +311 -0
  52. package/dist/composer/pure_fn/runtests.py +155 -0
  53. package/dist/composer/registry/__init__.py +40 -0
  54. package/dist/composer/registry/generate.py +422 -0
  55. package/dist/composer/registry/models.py +331 -0
  56. package/dist/composer/registry/nodes.py +673 -0
  57. package/dist/composer/requirements.txt +21 -0
  58. package/dist/composer/templates/__init__.py +13 -0
  59. package/dist/composer/templates/scan.py.j2 +31 -0
  60. package/dist/composer/validator/__init__.py +29 -0
  61. package/dist/composer/validator/desugar.py +133 -0
  62. package/dist/composer/validator/errors.py +191 -0
  63. package/dist/composer/validator/graph_checks.py +348 -0
  64. package/dist/composer/validator/loader.py +110 -0
  65. package/dist/composer/validator/schema_checks.py +147 -0
  66. package/dist/composer/validator/validate.py +86 -0
  67. package/dist/composer/verbs/__init__.py +33 -0
  68. package/dist/composer/verbs/check.py +193 -0
  69. package/dist/composer/verbs/deploy.py +223 -0
  70. package/dist/composer/verbs/docs.py +30 -0
  71. package/dist/composer/verbs/runtime_validate.py +262 -0
  72. package/dist/composer/verbs/smoke.py +115 -0
  73. package/dist/composer/verbs/status.py +205 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +2 -9
  76. package/dist/index.js.map +1 -1
  77. package/dist/runtime-api/config.d.ts +2 -2
  78. package/dist/runtime-api/config.js +4 -4
  79. package/dist/runtime-api/config.js.map +1 -1
  80. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  81. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  82. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  83. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +2 -8
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/skills-manager/manifest.js +1 -1
  88. package/dist/skills-manager/manifest.js.map +1 -1
  89. package/dist/skills-manager/skills-manifest.json +1 -1
  90. package/package.json +4 -2
  91. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  92. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  93. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  94. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,86 @@
1
+ """validate — the pipeline that runs the stages and collects every error together.
2
+
3
+ Stage order (each stage's findings are added to the same :class:`ValidationResult`;
4
+ stages do not stop at the first error — the whole point is to report every defect at
5
+ once so the agent repairs in one pass):
6
+
7
+ (a) load + anchor/alias/merge rejection -> loader
8
+ (b) `pipeline:` desugaring into nodes+edges -> desugar (before schema)
9
+ (c) JSON Schema validation (registry-generated) -> schema_checks
10
+ (d) graph-semantic checks -> graph_checks
11
+ (e) pure_fn checks (purity + inline tests) -> composer.pure_fn.checks
12
+
13
+ A hard load failure (a) is terminal — there is no document to check further. Everything
14
+ after that accumulates.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from composer.pure_fn import checks as pure_fn_checks
22
+
23
+ from . import desugar as _desugar
24
+ from . import graph_checks, schema_checks
25
+ from .errors import Location, TeachingError, ValidationResult
26
+ from .loader import AnchorError, YamlLoadError, load_graph
27
+
28
+
29
+ def validate_text(text: str, source: str = "<text>") -> ValidationResult:
30
+ result = ValidationResult(source=source)
31
+
32
+ # (a) load + anchor rejection
33
+ try:
34
+ data, line_index = load_graph(text)
35
+ except AnchorError as exc:
36
+ result.add(
37
+ TeachingError(
38
+ code="CMP002",
39
+ message=f"YAML {exc.what} is not allowed in a strategy graph.",
40
+ fix_hint="Remove the anchor/alias/merge key and write the value out in full (D6 bans them).",
41
+ location=Location(line=exc.line),
42
+ )
43
+ )
44
+ return result
45
+ except YamlLoadError as exc:
46
+ result.add(
47
+ TeachingError(
48
+ code="CMP001",
49
+ message=f"YAML syntax error: {exc.message}",
50
+ fix_hint="Fix the YAML syntax at the reported line (check indentation and quoting).",
51
+ location=Location(line=exc.line),
52
+ )
53
+ )
54
+ return result
55
+
56
+ if not isinstance(data, dict):
57
+ result.add(
58
+ TeachingError(
59
+ code="CMP003",
60
+ message="the document root is empty or not a mapping.",
61
+ fix_hint="A graph is a mapping with at least a `nodes:` list (or a `pipeline:` shorthand).",
62
+ )
63
+ )
64
+ return result
65
+
66
+ # (b) desugar pipeline -> nodes+edges (before schema)
67
+ if _desugar.has_pipeline(data):
68
+ data, desugar_errors = _desugar.desugar(data)
69
+ result.extend(desugar_errors)
70
+
71
+ # (c) JSON Schema validation
72
+ result.extend(schema_checks.schema_errors(data, line_index))
73
+
74
+ # (d) graph-semantic checks
75
+ result.extend(graph_checks.check(data, line_index))
76
+
77
+ # (e) pure_fn purity + inline fixture tests (D2 kind 2)
78
+ result.extend(pure_fn_checks.check(data, line_index))
79
+
80
+ return result
81
+
82
+
83
+ def validate_file(path: str | Path) -> ValidationResult:
84
+ p = Path(path)
85
+ text = p.read_text(encoding="utf-8")
86
+ return validate_text(text, source=str(p))
@@ -0,0 +1,33 @@
1
+ """verbs — the slice-7 composer verbs (check / deploy / status / catalog / describe).
2
+
3
+ Each verb is a thin, pure-ish Python surface producing a structured result with both a
4
+ human ``render()`` and a ``to_json()`` (the shape the TS rim passes through). The lifecycle
5
+ they implement — ``new → check → deploy``, with ``status`` deriving position and naming the
6
+ next verb — is D7; the TS wrapper is verdict-passthrough only (D8).
7
+
8
+ check — ONE verdict aggregating validate + pure_fn tests + compile + fixture smoke.
9
+ deploy — idempotent, HARD-gated on check; stages the deployable artifact set.
10
+ status — DERIVED lifecycle position + next verb (no journals; the spec file is state).
11
+ docs — catalog index + per-node describe (registry-generated).
12
+ """
13
+
14
+ from .check import CheckResult, check_file, run_check
15
+ from .deploy import DeployResult, deploy_file, run_deploy
16
+ from .docs import catalog_text, describe_text
17
+ from .smoke import SmokeError, run_smoke
18
+ from .status import StatusResult, run_status
19
+
20
+ __all__ = [
21
+ "CheckResult",
22
+ "run_check",
23
+ "check_file",
24
+ "DeployResult",
25
+ "run_deploy",
26
+ "deploy_file",
27
+ "StatusResult",
28
+ "run_status",
29
+ "catalog_text",
30
+ "describe_text",
31
+ "SmokeError",
32
+ "run_smoke",
33
+ ]
@@ -0,0 +1,193 @@
1
+ """check — ONE verdict aggregating the whole build gate (D7).
2
+
3
+ ``composer check <graph.yaml>`` collapses everything that must be true before a graph is
4
+ deployable into a single GREEN/RED verdict, run as ordered stages that short-circuit:
5
+
6
+ validate schema + graph-semantic + pure_fn PURITY checks (the teaching validator).
7
+ pure_fn_tests the pure_fn inline fixture tests (CMP05x), reported as their own stage.
8
+ compile deterministic materialization to scan.py on the external_scanner contract.
9
+ smoke import the emitted module + run one scan() tick over a stub ctx (liveness).
10
+ runtime_validate PROTECTION policy (CMP120) + the REAL Zod+DSL oracle over the emitted
11
+ runtime.yaml (CMP1xx). Protection-by-default is enforced HERE: an unmanaged
12
+ unit with no explicit opt-out is RED. The Zod oracle needs a built dist/; when
13
+ absent (CI without `npm run build`) it is noted SKIPPED, not RED — the policy
14
+ layer still gates. Opt-out (`unmanaged=true`) is GREEN with a loud warning.
15
+
16
+ The validator runs purity AND the inline fixture tests in one pass, so rather than re-run
17
+ them we PARTITION that single run's errors: CMP05x (inline-test) findings become the
18
+ ``pure_fn_tests`` stage; everything else is the ``validate`` stage. A stage is SKIPPED once
19
+ an earlier stage is RED (compiling an invalid graph, or smoking a graph that won't compile,
20
+ would only produce noise). The verdict is GREEN iff every stage is GREEN.
21
+
22
+ The result is both human-rendered and ``--json`` (verdict + per-stage status + located
23
+ errors). ``deploy`` consumes this object directly as its hard gate; the compiled source is
24
+ carried on the result so ``deploy`` need not recompile.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ from composer.compiler import CompileError, compile_text
34
+ from composer.validator import validate_text
35
+ from composer.validator.errors import TeachingError
36
+ from composer.validator.loader import load_graph
37
+
38
+ from .runtime_validate import check_runtime_unit
39
+ from .smoke import SmokeError, run_smoke
40
+
41
+ # Inline pure_fn fixture-test error codes (partitioned into their own stage).
42
+ _PURE_FN_TEST_CODES = {"CMP050", "CMP051", "CMP052"}
43
+
44
+ GREEN = "GREEN"
45
+ RED = "RED"
46
+ SKIPPED = "SKIPPED"
47
+
48
+
49
+ @dataclass
50
+ class Stage:
51
+ name: str
52
+ status: str = SKIPPED
53
+ errors: list[TeachingError] = field(default_factory=list)
54
+ # free-text findings for stages that don't produce TeachingErrors (compile / smoke)
55
+ messages: list[str] = field(default_factory=list)
56
+ detail: Optional[dict] = None
57
+
58
+ def to_json(self) -> dict:
59
+ out: dict = {
60
+ "stage": self.name,
61
+ "status": self.status,
62
+ "errors": [e.to_dict() for e in self.errors],
63
+ }
64
+ if self.messages:
65
+ out["messages"] = list(self.messages)
66
+ if self.detail is not None:
67
+ out["detail"] = self.detail
68
+ return out
69
+
70
+
71
+ @dataclass
72
+ class CheckResult:
73
+ source: str
74
+ stages: list[Stage]
75
+ scan_source: Optional[str] = None # the emitted scan.py when compile succeeded
76
+ runtime_source: Optional[str] = None # the emitted runtime.yaml when runtime_validate ran
77
+
78
+ @property
79
+ def verdict(self) -> str:
80
+ return GREEN if all(s.status == GREEN for s in self.stages) else RED
81
+
82
+ @property
83
+ def ok(self) -> bool:
84
+ return self.verdict == GREEN
85
+
86
+ def to_json(self) -> dict:
87
+ errors = [e.to_dict() for s in self.stages for e in s.errors]
88
+ return {
89
+ "verb": "check",
90
+ "graph": self.source,
91
+ "verdict": self.verdict,
92
+ "stages": [s.to_json() for s in self.stages],
93
+ "error_count": len(errors),
94
+ "errors": errors,
95
+ }
96
+
97
+ def render(self) -> str:
98
+ lines = [f"{self.verdict}: {self.source}"]
99
+ for s in self.stages:
100
+ mark = {"GREEN": "ok ", "RED": "FAIL", "SKIPPED": "skip"}[s.status]
101
+ lines.append(f" [{mark}] {s.name}")
102
+ for e in sorted(s.errors, key=lambda e: e.sort_key):
103
+ lines.append(" " + e.render().replace("\n", "\n "))
104
+ for m in s.messages:
105
+ lines.append(f" {m}")
106
+ lines.append("")
107
+ lines.append(
108
+ "check GREEN — ready to deploy." if self.ok
109
+ else "check RED — fix the failing stage(s); deploy is refused until GREEN."
110
+ )
111
+ return "\n".join(lines) + "\n"
112
+
113
+
114
+ def run_check(source_text: str, source_name: str) -> CheckResult:
115
+ """Run the whole gate over graph YAML text; return the aggregated verdict."""
116
+ validate_stage = Stage("validate")
117
+ pure_fn_stage = Stage("pure_fn_tests")
118
+ compile_stage = Stage("compile")
119
+ smoke_stage = Stage("smoke")
120
+ runtime_stage = Stage("runtime_validate")
121
+ stages = [validate_stage, pure_fn_stage, compile_stage, smoke_stage, runtime_stage]
122
+
123
+ # --- validate (+ partition the pure_fn inline-test findings into their own stage) ---
124
+ vresult = validate_text(source_text, source=source_name)
125
+ for err in vresult.errors:
126
+ (pure_fn_stage if err.code in _PURE_FN_TEST_CODES else validate_stage).errors.append(err)
127
+ validate_stage.status = RED if validate_stage.errors else GREEN
128
+ pure_fn_stage.status = RED if pure_fn_stage.errors else GREEN
129
+
130
+ if not vresult.ok:
131
+ # compile/smoke stay SKIPPED — nothing valid to materialize.
132
+ return CheckResult(source_name, stages)
133
+
134
+ # --- compile ---
135
+ scan_source: Optional[str] = None
136
+ try:
137
+ scan_source = compile_text(source_text, source_name=source_name)
138
+ compile_stage.status = GREEN
139
+ except CompileError as exc:
140
+ compile_stage.status = RED
141
+ compile_stage.messages.append(str(exc).splitlines()[0])
142
+ return CheckResult(source_name, stages, scan_source=None)
143
+
144
+ # --- smoke (import + one tick over a stub ctx) ---
145
+ try:
146
+ detail = run_smoke(scan_source, scanner_name=source_name)
147
+ smoke_stage.status = GREEN
148
+ smoke_stage.detail = detail
149
+ except SmokeError as exc:
150
+ smoke_stage.status = RED
151
+ smoke_stage.messages.append(str(exc))
152
+ return CheckResult(source_name, stages, scan_source=scan_source)
153
+
154
+ # --- runtime_validate (protection policy + the real Zod+DSL oracle over runtime.yaml) ---
155
+ runtime_source = _run_runtime_validate(source_text, runtime_stage)
156
+
157
+ return CheckResult(source_name, stages, scan_source=scan_source, runtime_source=runtime_source)
158
+
159
+
160
+ def _run_runtime_validate(source_text: str, stage: Stage) -> Optional[str]:
161
+ """Emit runtime.yaml from the graph's metadata, enforce PROTECTION policy (CMP120), and run
162
+ the real Zod+DSL oracle. Records status/errors/messages on ``stage``; returns the emitted
163
+ runtime.yaml text (so deploy can stage it without re-emitting)."""
164
+ from composer.authoring.runtime_emit import answers_from_metadata, render_runtime_yaml
165
+
166
+ try:
167
+ doc, _ = load_graph(source_text)
168
+ answers = answers_from_metadata(doc.get("metadata") or {})
169
+ except Exception as exc: # noqa: BLE001 — a bad metadata block is a graph defect, reported here
170
+ stage.status = RED
171
+ stage.messages.append(f"could not derive runtime answers from metadata: {exc}")
172
+ return None
173
+
174
+ runtime_source = render_runtime_yaml(answers)
175
+ oracle, policy_errors = check_runtime_unit(answers)
176
+ stage.errors.extend(policy_errors)
177
+ stage.errors.extend(oracle.errors)
178
+
179
+ if answers.unmanaged:
180
+ stage.messages.append(
181
+ "WARNING: unmanaged=true — this unit ships with NO exit protection (no stop-loss, "
182
+ "no trailing floor, no time-cut). Positions can run to liquidation. Opt-out accepted."
183
+ )
184
+ if oracle.skipped and oracle.skip_reason:
185
+ stage.messages.append(oracle.skip_reason)
186
+
187
+ stage.status = RED if stage.errors else GREEN
188
+ return runtime_source
189
+
190
+
191
+ def check_file(path) -> CheckResult:
192
+ p = Path(path)
193
+ return run_check(p.read_text(encoding="utf-8"), p.name)
@@ -0,0 +1,223 @@
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/<strategy>.graph.yaml the spec-is-state graph source (D7), under the instance.
15
+ manifest.json content hashes (graph + scan + runtime.yaml), schema version,
16
+ node count, and the zod-oracle verdict for the staged runtime.
17
+ deploy.json the verdict describing exactly what happened.
18
+ 3. Is IDEMPOTENT: re-deploying with identical graph/scan/runtime bytes is a no-op with
19
+ verdict ``UNCHANGED`` — the spec file is the identity, matching status's staleness derivation.
20
+
21
+ The staged unit is COMPLETE: it carries its own ``exit`` (DSL protection), its ``OPEN_POSITION``
22
+ entry action, and a funded-wallet manifest — no host runtime.yaml to borrow (the slice-8 wrap
23
+ hack is retired). Ops (``senpi-strategy-ops``) begins where the composer ends: it reads
24
+ ``strategy.yaml.instances[]``, mints one wallet per instance, funds by ``funding_share``, and
25
+ renders each ``runtime.yaml`` onto its wallet (``openclaw senpi runtime create``). The composer
26
+ never creates wallets, moves money, or installs (design §7 scope boundary).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Optional
36
+
37
+ from composer.authoring.runtime_emit import answers_from_metadata, render_runtime_yaml
38
+ from composer.authoring.strategy_manifest import render_strategy_yaml
39
+ from composer.registry import SCHEMA_VERSION
40
+ from composer.validator.loader import load_graph
41
+
42
+ from .check import CheckResult, run_check
43
+ from .runtime_validate import validate_runtime_yaml_file
44
+
45
+ DEPLOYED = "DEPLOYED"
46
+ UNCHANGED = "UNCHANGED"
47
+ REFUSED = "REFUSED"
48
+
49
+ # How ops installs the staged unit (documented handoff, not invoked here).
50
+ _OPS_MECHANISM = "senpi-strategy-ops deploy.py create/runtime — one wallet per instance, then `openclaw senpi runtime create`"
51
+
52
+
53
+ def _sha256(text: str) -> str:
54
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
55
+
56
+
57
+ @dataclass
58
+ class DeployResult:
59
+ source: str
60
+ verdict: str
61
+ check: CheckResult
62
+ staged_dir: Optional[str] = None
63
+ manifest: Optional[dict] = None
64
+
65
+ @property
66
+ def ok(self) -> bool:
67
+ return self.verdict in (DEPLOYED, UNCHANGED)
68
+
69
+ def to_json(self) -> dict:
70
+ out: dict = {
71
+ "verb": "deploy",
72
+ "graph": self.source,
73
+ "verdict": self.verdict,
74
+ "check": {
75
+ "verdict": self.check.verdict,
76
+ "error_count": self.check.to_json()["error_count"],
77
+ },
78
+ }
79
+ if self.verdict == REFUSED:
80
+ out["check"]["errors"] = self.check.to_json()["errors"]
81
+ out["message"] = "check RED — deploy refused (no --force; a graph must be GREEN)."
82
+ return out
83
+ man = self.manifest or {}
84
+ out["staged"] = {
85
+ "dir": self.staged_dir,
86
+ "artifacts": sorted(man.get("artifacts", {}).keys()),
87
+ "graph_sha256": man.get("graph_sha256"),
88
+ "scan_sha256": man.get("scan_sha256"),
89
+ "runtime_sha256": man.get("runtime_sha256"),
90
+ "strategy_sha256": man.get("strategy_sha256"),
91
+ "schema_version": man.get("schema_version"),
92
+ "runtime_zod_verdict": man.get("runtime", {}).get("zod_verdict"),
93
+ }
94
+ # The staged unit is COMPLETE and standalone — its own exit + entry action + funding
95
+ # manifest. Ops installs it; there is no host runtime.yaml to borrow (the wrap hack is gone).
96
+ out["install"] = {
97
+ "wired": False,
98
+ "handoff": _OPS_MECHANISM,
99
+ "complete_unit": True,
100
+ "note": (
101
+ "a COMPLETE standalone strategy runtime is staged: main/runtime.yaml carries the "
102
+ "OPEN_POSITION entry action AND the DSL exit (protection-by-default), plus a "
103
+ "position_tracker. Hand the staged dir to senpi-strategy-ops: it reads "
104
+ "strategy.yaml.instances[], mints one funded wallet per instance (funding_share), "
105
+ "and renders each runtime.yaml onto its wallet. The composer never funds or installs."
106
+ ),
107
+ }
108
+ out["message"] = (
109
+ f"{self.verdict.lower()}: complete unit staged to {self.staged_dir}"
110
+ if self.verdict == DEPLOYED
111
+ else f"unchanged: staged unit at {self.staged_dir} matches (idempotent no-op)."
112
+ )
113
+ return out
114
+
115
+ def render(self) -> str:
116
+ if self.verdict == REFUSED:
117
+ return (
118
+ f"REFUSED: {self.source} — check is {self.check.verdict} "
119
+ f"({self.check.to_json()['error_count']} error(s)); deploy is hard-gated on a "
120
+ f"GREEN check (there is no --force). Run `composer check {self.source}`.\n"
121
+ )
122
+ j = self.to_json()
123
+ s = j["staged"]
124
+ lines = [
125
+ f"{self.verdict}: {self.source}",
126
+ f" staged dir : {self.staged_dir}",
127
+ f" artifacts : {', '.join(s['artifacts'])}",
128
+ f" graph sha : {(s['graph_sha256'] or '')[:12]}…",
129
+ f" scan sha : {(s['scan_sha256'] or '')[:12]}…",
130
+ f" runtime sha: {(s['runtime_sha256'] or '')[:12]}… (zod: {s['runtime_zod_verdict']})",
131
+ f" install : complete standalone unit — hand off to ops ({_OPS_MECHANISM}).",
132
+ ]
133
+ return "\n".join(lines) + "\n"
134
+
135
+
136
+ def run_deploy(source_text: str, source_name: str, out_dir) -> DeployResult:
137
+ """Check, then stage the complete standalone unit as a package tree (idempotent). See docstring."""
138
+ check = run_check(source_text, source_name)
139
+ if not check.ok:
140
+ return DeployResult(source_name, REFUSED, check)
141
+
142
+ doc, _line_index = load_graph(source_text)
143
+ metadata = doc.get("metadata") or {}
144
+ strategy = str(metadata.get("strategy") or Path(source_name).stem.replace(".graph", ""))
145
+ archetype = str(metadata.get("archetype") or "unknown")
146
+
147
+ # The runtime.yaml + strategy.yaml derive from the SAME metadata.answers the scan.py did
148
+ # (design §6): recompiling regenerates all three byte-identically.
149
+ answers = answers_from_metadata(metadata)
150
+ scan_source = check.scan_source or ""
151
+ runtime_source = check.runtime_source or render_runtime_yaml(answers)
152
+ strategy_source = render_strategy_yaml(answers)
153
+
154
+ graph_sha = _sha256(source_text)
155
+ scan_sha = _sha256(scan_source)
156
+ runtime_sha = _sha256(runtime_source)
157
+ strategy_sha = _sha256(strategy_source)
158
+
159
+ stage_dir = Path(out_dir) / strategy
160
+ instance_dir = stage_dir / "main"
161
+ scanners_dir = instance_dir / "scanners"
162
+ manifest_path = stage_dir / "manifest.json"
163
+ graph_filename = f"{strategy}.graph.yaml"
164
+
165
+ # Idempotency: the spec file IS the identity (D7). A prior staging whose manifest records
166
+ # the same graph + scan + runtime bytes is an UNCHANGED no-op — don't rewrite anything.
167
+ if manifest_path.is_file():
168
+ try:
169
+ prior = json.loads(manifest_path.read_text(encoding="utf-8"))
170
+ except (OSError, json.JSONDecodeError):
171
+ prior = {}
172
+ if (
173
+ prior.get("graph_sha256") == graph_sha
174
+ and prior.get("scan_sha256") == scan_sha
175
+ and prior.get("runtime_sha256") == runtime_sha
176
+ ):
177
+ return DeployResult(source_name, UNCHANGED, check,
178
+ staged_dir=str(stage_dir), manifest=prior)
179
+
180
+ manifest = {
181
+ "strategy": strategy,
182
+ "archetype": archetype,
183
+ "schema_version": SCHEMA_VERSION,
184
+ "node_count": len(doc.get("nodes") or []),
185
+ "graph_sha256": graph_sha,
186
+ "scan_sha256": scan_sha,
187
+ "runtime_sha256": runtime_sha,
188
+ "strategy_sha256": strategy_sha,
189
+ "artifacts": {
190
+ "strategy.yaml": strategy_sha,
191
+ "main/runtime.yaml": runtime_sha,
192
+ "main/scanners/scan.py": scan_sha,
193
+ f"main/{graph_filename}": graph_sha,
194
+ },
195
+ "runtime": {"path": "main/runtime.yaml", "sha256": runtime_sha},
196
+ "generated_by": "composer deploy",
197
+ }
198
+
199
+ # Write the complete package tree.
200
+ scanners_dir.mkdir(parents=True, exist_ok=True)
201
+ (scanners_dir / "scan.py").write_text(scan_source, encoding="utf-8")
202
+ (instance_dir / "runtime.yaml").write_text(runtime_source, encoding="utf-8")
203
+ (instance_dir / graph_filename).write_text(source_text, encoding="utf-8")
204
+ (stage_dir / "strategy.yaml").write_text(strategy_source, encoding="utf-8")
205
+
206
+ # Prove the STAGED runtime.yaml validates against the real oracle; record the verdict.
207
+ ov = validate_runtime_yaml_file(instance_dir / "runtime.yaml")
208
+ manifest["runtime"]["zod_verdict"] = (
209
+ "SKIPPED" if ov.skipped else ("VALID" if ov.ok else "INVALID")
210
+ )
211
+
212
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
213
+
214
+ result = DeployResult(source_name, DEPLOYED, check, staged_dir=str(stage_dir), manifest=manifest)
215
+ (stage_dir / "deploy.json").write_text(
216
+ json.dumps(result.to_json(), indent=2, sort_keys=True) + "\n", encoding="utf-8"
217
+ )
218
+ return result
219
+
220
+
221
+ def deploy_file(path, out_dir) -> DeployResult:
222
+ p = Path(path)
223
+ 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)