plm-engine-core 0.1.0a0__py3-none-any.whl

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 (53) hide show
  1. plm_engine_core/__init__.py +24 -0
  2. plm_engine_core/agent_runtime/__init__.py +31 -0
  3. plm_engine_core/agent_runtime/dispatcher.py +343 -0
  4. plm_engine_core/agent_runtime/selector.py +154 -0
  5. plm_engine_core/cli/__init__.py +64 -0
  6. plm_engine_core/cli/__main__.py +15 -0
  7. plm_engine_core/cli/auth.py +125 -0
  8. plm_engine_core/control_plane/__init__.py +52 -0
  9. plm_engine_core/control_plane/_lint_fixtures/__init__.py +16 -0
  10. plm_engine_core/control_plane/_lint_fixtures/violation_demo.py +28 -0
  11. plm_engine_core/control_plane/capability_registry/__init__.py +45 -0
  12. plm_engine_core/control_plane/capability_registry/errors.py +71 -0
  13. plm_engine_core/control_plane/capability_registry/registry.py +403 -0
  14. plm_engine_core/control_plane/connectors/__init__.py +53 -0
  15. plm_engine_core/control_plane/connectors/dispatcher.py +148 -0
  16. plm_engine_core/control_plane/connectors/runtime.py +214 -0
  17. plm_engine_core/control_plane/dispatch.py +180 -0
  18. plm_engine_core/control_plane/hitl/__init__.py +45 -0
  19. plm_engine_core/control_plane/hitl/errors.py +74 -0
  20. plm_engine_core/control_plane/hitl/gate.py +555 -0
  21. plm_engine_core/control_plane/hitl/sql_store.py +380 -0
  22. plm_engine_core/control_plane/identity/__init__.py +46 -0
  23. plm_engine_core/control_plane/identity/middleware.py +349 -0
  24. plm_engine_core/control_plane/identity/provider.py +249 -0
  25. plm_engine_core/control_plane/identity/system_identity.py +92 -0
  26. plm_engine_core/control_plane/identity/telemetry.py +146 -0
  27. plm_engine_core/control_plane/knowledge/__init__.py +79 -0
  28. plm_engine_core/control_plane/knowledge/curation_index.py +119 -0
  29. plm_engine_core/control_plane/knowledge/envelope.py +358 -0
  30. plm_engine_core/control_plane/knowledge/runtime.py +205 -0
  31. plm_engine_core/control_plane/policies/__init__.py +74 -0
  32. plm_engine_core/control_plane/policies/authorization.py +192 -0
  33. plm_engine_core/control_plane/policies/autonomy_gating.py +273 -0
  34. plm_engine_core/control_plane/policies/base.py +174 -0
  35. plm_engine_core/control_plane/policies/cost_budget.py +490 -0
  36. plm_engine_core/control_plane/policies/rate_limit.py +369 -0
  37. plm_engine_core/control_plane/retry_policy.py +288 -0
  38. plm_engine_core/control_plane/run_task_tracker.py +262 -0
  39. plm_engine_core/mcp/__init__.py +63 -0
  40. plm_engine_core/mcp/asgi.py +195 -0
  41. plm_engine_core/mcp/errors.py +41 -0
  42. plm_engine_core/mcp/manifest.py +170 -0
  43. plm_engine_core/mcp/openapi.py +605 -0
  44. plm_engine_core/mcp/server.py +219 -0
  45. plm_engine_core/mcp/tools.py +544 -0
  46. plm_engine_core/studio/__init__.py +11 -0
  47. plm_engine_core/studio/readers/__init__.py +25 -0
  48. plm_engine_core/studio/readers/mcrc_v1.py +221 -0
  49. plm_engine_core-0.1.0a0.dist-info/METADATA +179 -0
  50. plm_engine_core-0.1.0a0.dist-info/RECORD +53 -0
  51. plm_engine_core-0.1.0a0.dist-info/WHEEL +5 -0
  52. plm_engine_core-0.1.0a0.dist-info/entry_points.txt +2 -0
  53. plm_engine_core-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,24 @@
1
+ """plm-engine-core — TracePulse PLM Engine Core package (US-CR.1 scaffold).
2
+
3
+ Internally split into two sub-packages aligned with target architecture
4
+ invariant #5 ("execution separated from expertise") and decision
5
+ D-LOCKED-13:
6
+
7
+ * `control_plane/` — governance, autonomy, HITL, run lifecycle,
8
+ trace propagation. Owns *what is allowed*.
9
+ * `agent_runtime/` — selector, dispatcher, retry, escalation.
10
+ Owns *how skills execute*.
11
+
12
+ One-way import contract: `control_plane` MUST NOT import from
13
+ `agent_runtime`. Mechanical enforcement (import-linter rule) lands
14
+ in US-CR.2; until then the rule is documented in README and observed
15
+ by convention.
16
+
17
+ US-CR.1 ships this scaffold deliberately empty — every other US-CR.*
18
+ story (CR.0 identity middleware, CR.2 boundary lint, CR.3-6 policies,
19
+ CR.7 MCP server, CR.8 OpenAPI, CR.9 federated manifest, CR.10 selector,
20
+ CR.11 retry, CR.12 capability registry, CR.13 HITL) lands its
21
+ implementation inside these clear, enforceable internal boundaries
22
+ from day one.
23
+ """
24
+ __version__ = "0.1.0-alpha"
@@ -0,0 +1,31 @@
1
+ """plm_engine_core.agent_runtime — selector + dispatcher + retry +
2
+ escalation (US-CR.1 scaffold).
3
+
4
+ Owns *how skills execute*: skill selection, Kernel dispatch, retry
5
+ orchestration on transient failures, escalation when retries
6
+ exhaust. Downstream stories land here:
7
+
8
+ * US-CR.10 — skill selector → Kernel dispatch loopback (runtime side).
9
+ * US-CR.11 — externalised RetryPolicy module.
10
+
11
+ `agent_runtime` may import from `plm_shared.*` (frozen contracts) and
12
+ from `plm_engine_core.control_plane` is **forbidden** by the one-way
13
+ import rule. Decisions about what is allowed live in `control_plane`;
14
+ the runtime asks control_plane for verdicts but does not depend on
15
+ its internals.
16
+
17
+ The `__all__` placeholders below name the public surface downstream
18
+ stories will populate. Importing any of them today raises
19
+ `ImportError` because the modules don't exist yet — placeholder
20
+ list is the contract, not a runtime shim.
21
+ """
22
+ __all__ = [
23
+ # US-CR.10 — skill selection from a tenant + capability scope.
24
+ "selector",
25
+ # Kernel dispatch loop (runtime side of US-CR.10).
26
+ "dispatcher",
27
+ # US-CR.11 — externalised retry policy (taxonomy-aware).
28
+ "retry",
29
+ # Escalation when retries exhaust or autonomy gate denies.
30
+ "escalation",
31
+ ]
@@ -0,0 +1,343 @@
1
+ """US-CR.10 — KernelDispatcher Protocol + V1 in-process adapter.
2
+
3
+ Decision #51 (Conv M / Q-M2=(c) "Both"): dispatcher lives here and
4
+ ``mcp/tools.py::_impl_skill_invoke`` is a thin wrapper around it.
5
+
6
+ **V1 transport scope.** The CR.10 §7.bis frozen wire contract names
7
+ HTTP loopback at ``POST /v1/skills/{id}/invoke`` against a Wave 2
8
+ Kernel stub. Wave 2 Conv A (D-CONV-M-4 closure) wires the actual
9
+ HTTP round-trip:
10
+
11
+ * :class:`KernelDispatcher` — the Protocol surface. Accepts an
12
+ invocation request + dispatch context, returns a result or
13
+ raises a typed terminal exception.
14
+ * :class:`InProcessKernelDispatcher` — the V1 adapter. Behaviour:
15
+
16
+ - ``SKILL_KERNEL_LOOPBACK`` off (default) → returns
17
+ ``KERNEL_DISPATCH_DISABLED`` (CR.10 §AC#7).
18
+ - ``SKILL_KERNEL_LOOPBACK`` on → posts the dispatch request to the
19
+ plm-skill-kernel server (default ``http://127.0.0.1:8100``) and
20
+ returns the resulting :class:`DispatchResult`. The Kernel URL is
21
+ overridable via ``SKILL_KERNEL_URL``. ``httpx`` is imported
22
+ lazily so the off-path doesn't require the dep on PYTHONPATH.
23
+
24
+ The Protocol surface is unchanged; only the V1.1 transport branch
25
+ moved from a deferred placeholder to a real round-trip.
26
+
27
+ Cancellation propagation (CR.10 §AC#5 + Decision #28 / CR.1b's
28
+ third deferred consumer): the dispatch context carries an optional
29
+ ``RunTaskTracker`` + ``run_id``; the dispatcher checks
30
+ ``is_cancelled(run_id)`` before contacting the transport AND short-
31
+ circuits the HTTP call when cancellation fires before the request
32
+ is sent.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import os
38
+ from dataclasses import dataclass
39
+ from typing import Any, Dict, Optional, Protocol
40
+
41
+ from plm_shared.capability_registry import CapabilityVersion
42
+
43
+ from ..control_plane.run_task_tracker import RunTaskTracker
44
+
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # Default Kernel URL — Q-W2A-3 chose a standalone Kernel process on
50
+ # port 8100; matches plm-skill-kernel/server.py:DEFAULT_PORT.
51
+ _DEFAULT_KERNEL_URL = "http://127.0.0.1:8100"
52
+ _KERNEL_HTTP_TIMEOUT_SECONDS = 30.0
53
+
54
+
55
+ # ── Feature flag ─────────────────────────────────────────────────────────
56
+
57
+
58
+ def _loopback_enabled() -> bool:
59
+ """Read the ``SKILL_KERNEL_LOOPBACK`` env flag.
60
+
61
+ Default off (CR.10 §AC#7). The flag exists from V1 so the
62
+ dispatcher layer is wire-compatible with the Wave 2 Kernel stub
63
+ swap; Conv M evaluates the flag to decide between
64
+ ``KERNEL_DISPATCH_DISABLED`` and the V1.1 stub.
65
+ """
66
+ return os.environ.get("SKILL_KERNEL_LOOPBACK", "").lower() in {
67
+ "1",
68
+ "true",
69
+ "on",
70
+ "yes",
71
+ }
72
+
73
+
74
+ # ── Public types ─────────────────────────────────────────────────────────
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class DispatchRequest:
79
+ """Inputs to the dispatcher.
80
+
81
+ Mirrors :class:`plm_shared.kernel_api.v1.SkillInvocationRequest`
82
+ field names — V1 doesn't yet serialise; V1.1 will.
83
+ """
84
+
85
+ skill_id: str
86
+ skill_version: CapabilityVersion
87
+ arguments: Dict[str, Any]
88
+ tenant_id: str
89
+ autonomy_level: str
90
+ idempotency_key: Optional[str] = None
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class DispatchContext:
95
+ """Per-call context — cancellation + trace.
96
+
97
+ Wired by CR.7's ``_impl_skill_invoke`` from the inbound MCP
98
+ ``ToolCallContext``.
99
+ """
100
+
101
+ run_id: Optional[str] = None
102
+ run_task_tracker: Optional[RunTaskTracker] = None
103
+ trace_id: Optional[str] = None
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class DispatchResult:
108
+ """V1 typed dispatch result.
109
+
110
+ ``ok=True`` carries a JSON-serialisable ``payload``. ``ok=False``
111
+ carries the D-AUDIT-7 ``error_code`` so callers can map to
112
+ :class:`ErrorEnvelope` without an extra match.
113
+ """
114
+
115
+ ok: bool
116
+ payload: Optional[Dict[str, Any]] = None
117
+ error_code: Optional[str] = None
118
+ error_message: Optional[str] = None
119
+ cancelled: bool = False
120
+
121
+
122
+ # ── Protocol ─────────────────────────────────────────────────────────────
123
+
124
+
125
+ class KernelDispatcher(Protocol):
126
+ """V1 dispatcher surface (CR.10 §7)."""
127
+
128
+ async def dispatch(
129
+ self,
130
+ request: DispatchRequest,
131
+ context: DispatchContext,
132
+ ) -> DispatchResult:
133
+ ...
134
+
135
+
136
+ # ── InProcessKernelDispatcher — V1 adapter ───────────────────────────────
137
+
138
+
139
+ class InProcessKernelDispatcher:
140
+ """V1 adapter.
141
+
142
+ Behaviour:
143
+
144
+ * Cancellation check first (CR.10 §AC#5).
145
+ * If ``SKILL_KERNEL_LOOPBACK`` is off → ``KERNEL_DISPATCH_DISABLED``
146
+ (CR.10 §AC#7).
147
+ * If on → V1 returns a typed ``deferred_to=US-CR.10-V1.1`` payload
148
+ announcing the Wave 2 Kernel stub deferral. The Wave 2 swap
149
+ replaces this branch with the HTTP round-trip; the Protocol
150
+ surface stays unchanged.
151
+ """
152
+
153
+ async def dispatch(
154
+ self,
155
+ request: DispatchRequest,
156
+ context: DispatchContext,
157
+ ) -> DispatchResult:
158
+ # AC#5 — cancel-on-disconnect propagates through the dispatch.
159
+ if (
160
+ context.run_task_tracker is not None
161
+ and context.run_id is not None
162
+ and context.run_task_tracker.is_cancelled(context.run_id)
163
+ ):
164
+ return DispatchResult(
165
+ ok=True,
166
+ payload={
167
+ "deferred": True,
168
+ "reason": "run cancelled before dispatch",
169
+ "deferred_to": "cancellation",
170
+ "skill_id": request.skill_id,
171
+ "skill_version": request.skill_version.version,
172
+ "cancelled": True,
173
+ },
174
+ cancelled=True,
175
+ )
176
+
177
+ if not _loopback_enabled():
178
+ return DispatchResult(
179
+ ok=False,
180
+ error_code="KERNEL_DISPATCH_DISABLED",
181
+ error_message=(
182
+ "SKILL_KERNEL_LOOPBACK is off; dispatch disabled in V1"
183
+ ),
184
+ )
185
+
186
+ # V1.1 transport (D-CONV-M-4 closure / Wave 2 Conv A item 8):
187
+ # POST to plm-skill-kernel's HTTP server. The deferred branch
188
+ # is GONE — the flag-on path is now a real round-trip.
189
+ return await self._dispatch_http_loopback(request, context)
190
+
191
+ async def _dispatch_http_loopback(
192
+ self,
193
+ request: DispatchRequest,
194
+ context: DispatchContext,
195
+ ) -> DispatchResult:
196
+ """V1.1 HTTP round-trip to plm-skill-kernel.
197
+
198
+ URL: ``${SKILL_KERNEL_URL or 127.0.0.1:8100}/v1/skills/{id}/invoke``.
199
+
200
+ Headers:
201
+
202
+ * ``X-Core-Caller`` — tenant_id is the V1 stand-in until the
203
+ V1.2 caller-UUID surface lands; matches the Conv F shape
204
+ where the middleware accepts a UUID-shaped opaque value.
205
+ * ``Idempotency-Key`` — propagated when the request carries one.
206
+ * ``traceparent`` — propagated from the dispatch context's
207
+ ``trace_id`` field (W3C format expected upstream).
208
+
209
+ Errors are mapped to typed :class:`DispatchResult` with a
210
+ D-AUDIT-7 ``error_code`` rather than raised — the dispatcher's
211
+ contract is "always returns a DispatchResult".
212
+ """
213
+ # Lazy import: the off-path doesn't need httpx, and even on-path
214
+ # tests can stub the import via monkeypatch.
215
+ try:
216
+ import httpx # type: ignore[import-not-found]
217
+ except ImportError:
218
+ return DispatchResult(
219
+ ok=False,
220
+ error_code="KERNEL_DISPATCH_FAILED",
221
+ error_message=(
222
+ "SKILL_KERNEL_LOOPBACK=on but httpx is not installed; "
223
+ "install httpx>=0.27 in the Engine Core environment"
224
+ ),
225
+ )
226
+
227
+ kernel_url = os.environ.get("SKILL_KERNEL_URL", _DEFAULT_KERNEL_URL)
228
+ url = f"{kernel_url.rstrip('/')}/v1/skills/{request.skill_id}/invoke"
229
+ body = {
230
+ "payload": dict(request.arguments),
231
+ "version": request.skill_version.version,
232
+ "autonomy_hint": request.autonomy_level,
233
+ }
234
+ headers: Dict[str, str] = {
235
+ # Tenant-as-X-Core-Caller is the V1 stand-in until the
236
+ # V1.2 caller-UUID surface (W2 Conv C cross-package
237
+ # cleanup) lands; tenant_id is already a UUID at the
238
+ # middleware layer, so the receiver can treat it as one.
239
+ "X-Core-Caller": request.tenant_id,
240
+ "X-Tenant-Id": request.tenant_id,
241
+ "Content-Type": "application/json",
242
+ }
243
+ if request.idempotency_key:
244
+ headers["Idempotency-Key"] = request.idempotency_key
245
+ if context.trace_id:
246
+ headers["traceparent"] = context.trace_id
247
+ if context.run_id:
248
+ headers["X-Run-Id"] = context.run_id
249
+
250
+ try:
251
+ async with httpx.AsyncClient(
252
+ timeout=_KERNEL_HTTP_TIMEOUT_SECONDS
253
+ ) as client:
254
+ response = await client.post(url, json=body, headers=headers)
255
+ except httpx.TimeoutException as exc:
256
+ logger.warning("kernel dispatch timeout url=%s err=%s", url, exc)
257
+ return DispatchResult(
258
+ ok=False,
259
+ error_code="KERNEL_DISPATCH_TIMEOUT",
260
+ error_message=f"kernel dispatch timed out after {_KERNEL_HTTP_TIMEOUT_SECONDS}s",
261
+ )
262
+ except httpx.HTTPError as exc:
263
+ logger.warning("kernel dispatch http error url=%s err=%s", url, exc)
264
+ return DispatchResult(
265
+ ok=False,
266
+ error_code="KERNEL_DISPATCH_FAILED",
267
+ error_message=f"kernel transport error: {exc}",
268
+ )
269
+
270
+ # AC#5 - re-check cancellation after the round-trip; a long
271
+ # call may have been cancelled while in-flight.
272
+ if (
273
+ context.run_task_tracker is not None
274
+ and context.run_id is not None
275
+ and context.run_task_tracker.is_cancelled(context.run_id)
276
+ ):
277
+ return DispatchResult(
278
+ ok=True,
279
+ payload={
280
+ "deferred": True,
281
+ "reason": "run cancelled during dispatch",
282
+ "deferred_to": "cancellation",
283
+ "skill_id": request.skill_id,
284
+ "skill_version": request.skill_version.version,
285
+ "cancelled": True,
286
+ },
287
+ cancelled=True,
288
+ )
289
+
290
+ try:
291
+ envelope = response.json()
292
+ except ValueError:
293
+ return DispatchResult(
294
+ ok=False,
295
+ error_code="KERNEL_DISPATCH_FAILED",
296
+ error_message=(
297
+ f"kernel returned non-JSON response (status={response.status_code})"
298
+ ),
299
+ )
300
+
301
+ if response.status_code >= 500:
302
+ return DispatchResult(
303
+ ok=False,
304
+ error_code="KERNEL_DISPATCH_FAILED",
305
+ error_message=(
306
+ f"kernel returned status {response.status_code}"
307
+ ),
308
+ )
309
+
310
+ ok = bool(envelope.get("ok"))
311
+ if not ok:
312
+ err = envelope.get("error") or {}
313
+ return DispatchResult(
314
+ ok=False,
315
+ error_code=err.get("error_code", "KERNEL_DISPATCH_FAILED"),
316
+ error_message=err.get(
317
+ "message", "kernel dispatch failed without message"
318
+ ),
319
+ )
320
+
321
+ payload = envelope.get("result") or {}
322
+ # Stamp skill identity for downstream consumers + match the
323
+ # placeholder shape Conv M was returning (skill_id /
324
+ # skill_version are the V1 contract on success).
325
+ if isinstance(payload, dict):
326
+ payload.setdefault("skill_id", request.skill_id)
327
+ payload.setdefault("skill_version", request.skill_version.version)
328
+
329
+ cancelled_flag = bool(envelope.get("cancelled") or False)
330
+ return DispatchResult(
331
+ ok=True,
332
+ payload=payload,
333
+ cancelled=cancelled_flag,
334
+ )
335
+
336
+
337
+ __all__ = [
338
+ "DispatchContext",
339
+ "DispatchRequest",
340
+ "DispatchResult",
341
+ "InProcessKernelDispatcher",
342
+ "KernelDispatcher",
343
+ ]
@@ -0,0 +1,154 @@
1
+ """US-CR.10 — SkillSelector Protocol + RegistrySkillSelector V1 adapter.
2
+
3
+ Decision #51 (Conv M / Q-M2=(c) "Both"): selector lives in
4
+ ``agent_runtime/`` per CR.10 §7 + the ``__init__.py`` placeholder
5
+ laid down by CR.1; CR.7's ``_impl_skill_invoke`` is a thin wiring
6
+ delegating into this module.
7
+
8
+ V1 selector reads CR.12's :class:`CapabilityRegistry` to resolve
9
+ ``(skill_id, version)`` to a runnable :class:`SkillResolution`.
10
+ Per CR.10 §6 main business rules: deterministic resolution,
11
+ explicit version honoured, gating refusals short-circuit dispatch.
12
+
13
+ LLM-based or heuristic routing is V1.1+ (CR.10 §"Functional out
14
+ of scope"). V1 is registry-driven only.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import List, Optional, Protocol
20
+
21
+ from plm_shared.capability_registry import (
22
+ AutonomyLevel,
23
+ CapabilityVersion,
24
+ GatingDecision,
25
+ )
26
+
27
+ from ..control_plane.capability_registry import (
28
+ CapabilityNoSelectableVersionError,
29
+ CapabilityNotFoundError,
30
+ CapabilityRegistry,
31
+ CapabilityRetiredError,
32
+ )
33
+
34
+
35
+ # ── Public types ──────────────────────────────────────────────────────────
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SkillResolution:
40
+ """Selector outcome — the resolved skill version + the gating verdict.
41
+
42
+ ``gating.allowed`` is True iff the call may proceed; the dispatcher
43
+ refuses dispatch when False and surfaces the gating reason.
44
+ """
45
+
46
+ skill_id: str
47
+ version: CapabilityVersion
48
+ gating: GatingDecision
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SelectorError:
53
+ """Typed error from the selector path.
54
+
55
+ Carries a D-AUDIT-7 code so the dispatcher / MCP tool can convert
56
+ it to an :class:`ErrorEnvelope` directly.
57
+ """
58
+
59
+ code: str
60
+ message: str
61
+ skill_id: str
62
+ requested_version: Optional[str] = None
63
+
64
+
65
+ # ── Protocol ──────────────────────────────────────────────────────────────
66
+
67
+
68
+ class SkillSelector(Protocol):
69
+ """V1 surface (CR.10 §7).
70
+
71
+ The selector resolves an intent + agent context to a single
72
+ runnable skill version. ``select`` returns a typed result OR a
73
+ typed error; bare exceptions never escape.
74
+ """
75
+
76
+ def select(
77
+ self,
78
+ skill_id: str,
79
+ *,
80
+ skill_version: Optional[str] = None,
81
+ tenant_id: str,
82
+ autonomy_level: AutonomyLevel,
83
+ granted_scopes: Optional[List[str]] = None,
84
+ ) -> SkillResolution | SelectorError:
85
+ ...
86
+
87
+
88
+ # ── RegistrySkillSelector — V1 adapter ───────────────────────────────────
89
+
90
+
91
+ class RegistrySkillSelector:
92
+ """V1 selector reading the dynamic Capability Registry."""
93
+
94
+ def __init__(self, registry: CapabilityRegistry) -> None:
95
+ self._registry = registry
96
+
97
+ def select(
98
+ self,
99
+ skill_id: str,
100
+ *,
101
+ skill_version: Optional[str] = None,
102
+ tenant_id: str,
103
+ autonomy_level: AutonomyLevel,
104
+ granted_scopes: Optional[List[str]] = None,
105
+ ) -> SkillResolution | SelectorError:
106
+ try:
107
+ ver = self._registry.negotiate_version(
108
+ skill_id,
109
+ requested_version=skill_version,
110
+ tenant_id=tenant_id,
111
+ )
112
+ except CapabilityNotFoundError:
113
+ return SelectorError(
114
+ code="SKILL_NOT_FOUND",
115
+ message=f"no skill registered for id={skill_id!r}",
116
+ skill_id=skill_id,
117
+ requested_version=skill_version,
118
+ )
119
+ except CapabilityRetiredError:
120
+ return SelectorError(
121
+ code="SKILL_VERSION_NOT_FOUND",
122
+ message=(
123
+ f"skill {skill_id!r} version {skill_version!r} is retired"
124
+ ),
125
+ skill_id=skill_id,
126
+ requested_version=skill_version,
127
+ )
128
+ except CapabilityNoSelectableVersionError:
129
+ return SelectorError(
130
+ code="CAPABILITY_NO_SELECTABLE_VERSION",
131
+ message=(
132
+ f"no selectable version for skill {skill_id!r}"
133
+ ),
134
+ skill_id=skill_id,
135
+ requested_version=skill_version,
136
+ )
137
+
138
+ gating = self._registry.is_gated(
139
+ skill_id,
140
+ version=ver.version,
141
+ tenant_id=tenant_id,
142
+ autonomy_level=autonomy_level,
143
+ granted_scopes=granted_scopes,
144
+ )
145
+
146
+ return SkillResolution(skill_id=skill_id, version=ver, gating=gating)
147
+
148
+
149
+ __all__ = [
150
+ "RegistrySkillSelector",
151
+ "SelectorError",
152
+ "SkillResolution",
153
+ "SkillSelector",
154
+ ]
@@ -0,0 +1,64 @@
1
+ """plm-cli — operator tooling for plm-engine-core (US-CR.0 PR-3 / Conv F).
2
+
3
+ Lives at the engine-core top level alongside the future Core MCP
4
+ server (US-CR.7) and OpenAPI artefact (US-CR.8) — neither
5
+ control_plane nor agent_runtime, per the CR.1 module placement
6
+ convention. CR.2's import-linter Forbidden contracts do not apply
7
+ here (the CLI is metadata, not a control_plane / agent_runtime
8
+ runtime path).
9
+
10
+ V1 commands:
11
+ plm-cli auth issue-token — mint an HS256 dev/staging token
12
+ consumed by IdentityMiddleware
13
+ (round-trips through PR-2's provider).
14
+
15
+ Future stories add subcommands (e.g. `plm-cli mcp serve` for CR.7);
16
+ the dispatcher below is a flat `command subcommand` shape that can
17
+ absorb new top-level commands without restructuring.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+ from typing import Optional, Sequence
24
+
25
+ from . import auth
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ """Argparse tree: `plm-cli {auth} {subcommand} ...`."""
30
+ parser = argparse.ArgumentParser(
31
+ prog="plm-cli",
32
+ description=(
33
+ "Operator tooling for plm-engine-core. V1 ships the "
34
+ "`auth` group; future stories add `mcp`, `policies`, etc."
35
+ ),
36
+ )
37
+ sub = parser.add_subparsers(dest="command", required=True)
38
+
39
+ auth_parser = sub.add_parser(
40
+ "auth",
41
+ help="Issue / inspect identity tokens (HS256 dev path).",
42
+ )
43
+ auth.add_subparsers(auth_parser)
44
+
45
+ return parser
46
+
47
+
48
+ def main(argv: Optional[Sequence[str]] = None) -> int:
49
+ """Entry point used by both the console_script and tests.
50
+
51
+ Tests should pass `argv=[...]` to drive the parser directly.
52
+ Console-script use leaves argv=None; argparse reads sys.argv.
53
+ Returns the process exit code (0 = success, non-zero = error).
54
+ """
55
+ parser = _build_parser()
56
+ args = parser.parse_args(argv)
57
+ if args.command == "auth":
58
+ return auth.dispatch(args)
59
+ parser.error(f"unknown command: {args.command}")
60
+ return 2 # unreachable; argparse exits
61
+
62
+
63
+ if __name__ == "__main__": # pragma: no cover
64
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ """Module-execution entry: `python -m plm_engine_core.cli ...`.
2
+
3
+ Mirrors the console_script `plm-cli` registered in pyproject.toml,
4
+ useful when the script-shim isn't on PATH (fresh editable install,
5
+ CI containers without entry-point shims).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from . import main
12
+
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())