rapier-runtime 0.1.0__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 (46) hide show
  1. rapier/__init__.py +70 -0
  2. rapier/_json.py +39 -0
  3. rapier/cli.py +244 -0
  4. rapier/convergence.py +93 -0
  5. rapier/envelope.py +66 -0
  6. rapier/ledger.py +71 -0
  7. rapier/manifest.py +75 -0
  8. rapier/mcp/__init__.py +9 -0
  9. rapier/mcp/server.py +184 -0
  10. rapier/mcp/tools.py +125 -0
  11. rapier/models.py +352 -0
  12. rapier/onboarding.py +128 -0
  13. rapier/pipeline.py +145 -0
  14. rapier/presets.py +83 -0
  15. rapier/secrets.py +72 -0
  16. rapier/stage.py +98 -0
  17. rapier/stages/__init__.py +6 -0
  18. rapier/stages/echo.py +28 -0
  19. rapier/stages/proposer/__init__.py +7 -0
  20. rapier/stages/proposer/phase_stages.py +101 -0
  21. rapier/stages/proposer/phases.py +201 -0
  22. rapier/stages/resolver/__init__.py +22 -0
  23. rapier/stages/resolver/_binding.py +33 -0
  24. rapier/stages/resolver/_extract.py +82 -0
  25. rapier/stages/resolver/anchored_fix.py +52 -0
  26. rapier/stages/resolver/author.py +62 -0
  27. rapier/stages/resolver/citation_gate.py +123 -0
  28. rapier/stages/resolver/compose.py +457 -0
  29. rapier/stages/resolver/cross_review.py +45 -0
  30. rapier/stages/resolver/definitiveness_gate.py +55 -0
  31. rapier/verify/__init__.py +4 -0
  32. rapier/verify/_bootstrap.py +66 -0
  33. rapier/verify/_llm_shim.py +131 -0
  34. rapier/verify/_vendor/PROVENANCE.md +14 -0
  35. rapier/verify/_vendor/cite_check.py +1093 -0
  36. rapier/verify/_vendor/lib_llm.py +288 -0
  37. rapier/verify/_vendor/spar_cross_review.py +296 -0
  38. rapier/verify/_vendor/spar_definitiveness_gate.py +770 -0
  39. rapier/verify/_vendor/spar_verify_gate.py +233 -0
  40. rapier/verify/_vendor/verify_grounding.py +795 -0
  41. rapier/verify/service.py +46 -0
  42. rapier_runtime-0.1.0.dist-info/METADATA +249 -0
  43. rapier_runtime-0.1.0.dist-info/RECORD +46 -0
  44. rapier_runtime-0.1.0.dist-info/WHEEL +4 -0
  45. rapier_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  46. rapier_runtime-0.1.0.dist-info/licenses/LICENSE +202 -0
rapier/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """Rapier Runtime — a code-orchestrated engine that runs the SPARRING method.
2
+
3
+ Rapier executes a SPARRING *method* declared in a YAML manifest: grounded,
4
+ cross-vendor adversarial review for AI-in-the-loop decisions. This is M0 — the
5
+ skeleton (Envelope, Stage contract, Pipeline controller, model/provider layer,
6
+ ledger) proven with a dummy echo stage. The Resolver port (M1) and the Proposer
7
+ build (M2) come next.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from .envelope import Artifact, Envelope, TraceEntry
12
+ from .manifest import Manifest
13
+ from .models import (
14
+ ModelClient,
15
+ ModelResponse,
16
+ ModelSpec,
17
+ OpenAICompatibleModelClient,
18
+ Policy,
19
+ PolicyError,
20
+ available_vendors,
21
+ build_client,
22
+ )
23
+ from .pipeline import Pipeline, StageSpec
24
+ from .stage import (
25
+ ConvergenceStage,
26
+ Stage,
27
+ StageContext,
28
+ TransformStage,
29
+ get_stage,
30
+ register_stage,
31
+ registered_stages,
32
+ )
33
+ from . import stages # noqa: F401 (registers built-in stages on import)
34
+
35
+ # Single source of truth is pyproject's version; read it from the installed
36
+ # package metadata so __version__ can never drift from what was published.
37
+ try:
38
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
39
+
40
+ try:
41
+ __version__ = _pkg_version("rapier-runtime")
42
+ except PackageNotFoundError: # running from a source tree, not installed
43
+ __version__ = "0.0.0+source"
44
+ except Exception: # pragma: no cover - defensive
45
+ __version__ = "0.0.0+source"
46
+
47
+ __all__ = [
48
+ "Envelope",
49
+ "Artifact",
50
+ "TraceEntry",
51
+ "Stage",
52
+ "TransformStage",
53
+ "ConvergenceStage",
54
+ "StageContext",
55
+ "register_stage",
56
+ "get_stage",
57
+ "registered_stages",
58
+ "Manifest",
59
+ "Pipeline",
60
+ "StageSpec",
61
+ "ModelSpec",
62
+ "ModelClient",
63
+ "ModelResponse",
64
+ "OpenAICompatibleModelClient",
65
+ "Policy",
66
+ "PolicyError",
67
+ "build_client",
68
+ "available_vendors",
69
+ "__version__",
70
+ ]
rapier/_json.py ADDED
@@ -0,0 +1,39 @@
1
+ """Tolerant JSON extraction from model output (fences, surrounding prose)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from typing import Any
7
+
8
+
9
+ def parse_json_lenient(text: str | None) -> Any:
10
+ """Best-effort: strip code fences, else grab the first balanced {...}/[...].
11
+
12
+ Returns {} on failure rather than raising — a malformed model reply becomes
13
+ an empty round, not a crash (fail-soft).
14
+ """
15
+ if not text:
16
+ return {}
17
+ t = text.strip()
18
+ t = re.sub(r"^```(?:json)?\s*", "", t)
19
+ t = re.sub(r"\s*```$", "", t)
20
+ try:
21
+ return json.loads(t)
22
+ except Exception:
23
+ pass
24
+ for opener, closer in (("{", "}"), ("[", "]")):
25
+ start = t.find(opener)
26
+ if start == -1:
27
+ continue
28
+ depth = 0
29
+ for i in range(start, len(t)):
30
+ if t[i] == opener:
31
+ depth += 1
32
+ elif t[i] == closer:
33
+ depth -= 1
34
+ if depth == 0:
35
+ try:
36
+ return json.loads(t[start : i + 1])
37
+ except Exception:
38
+ break
39
+ return {}
rapier/cli.py ADDED
@@ -0,0 +1,244 @@
1
+ """The ``rapier`` CLI.
2
+
3
+ rapier sparring --request "should we do X?" # full ceremony
4
+ rapier spar --request "should we do X?" # Resolver only
5
+ rapier spar --request-file pack.md # read the pack from a file
6
+ rapier proposer --request "should we do X?" # Proposer only
7
+ rapier run --manifest path.yaml --request-file pack.md # a custom manifest
8
+ rapier doctor # which vendor keys are set
9
+ rapier init # scaffold a .env.example
10
+ rapier mcp # run the MCP server (stdio)
11
+
12
+ ``spar`` / ``sparring`` are the thin adapters the SPARRING skills call.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import itertools
18
+ import os
19
+ import re
20
+ import sys
21
+ import tempfile
22
+ import threading
23
+ import time
24
+
25
+ from . import __version__, stages # noqa: F401 (ensure built-in stages are registered)
26
+ from .manifest import Manifest
27
+ from .presets import VERIFY_MODES, load_preset
28
+
29
+ # A ceremony is a sequence of model calls that each take tens of seconds — long
30
+ # enough that a novice fears it hung. Give each stage a plain-language label and,
31
+ # on a TTY, a live spinner + elapsed clock + "N/M" so it plainly reads as working.
32
+ _STAGE_LABELS = {
33
+ "author": "Drafting the recommendation",
34
+ "cross_review": "Independent cross-vendor challenge",
35
+ "anchored_fix": "Revising to address the challenge",
36
+ "definitiveness_gate": "Correctness check",
37
+ "citation_gate": "Grounding citations against public registries",
38
+ "compose": "Composing the report",
39
+ "spark": "Widening the options (SPARK)",
40
+ "pattern_lock": "Filtering repetition (Pattern Lock)",
41
+ "cut": "Committing one option (the Cut)",
42
+ "echo": "Echo",
43
+ }
44
+
45
+
46
+ class _Progress:
47
+ """Render stage progress. On a TTY: an in-place spinner + elapsed seconds +
48
+ N/M counter, animated on a background thread while the (blocking) stage runs.
49
+ Piped/redirected: plain one-line-per-stage output, no control characters, so
50
+ logs stay clean."""
51
+
52
+ _FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
53
+
54
+ def __init__(self, total: int, stream=None):
55
+ self.total = total
56
+ self.stream = stream or sys.stderr
57
+ self.tty = bool(getattr(self.stream, "isatty", lambda: False)())
58
+ self.i = 0
59
+ self._label = ""
60
+ self._start = 0.0
61
+ self._stop = None
62
+ self._thread = None
63
+
64
+ def log(self, msg: str) -> None:
65
+ m = re.match(r"stage: (\w+)", msg)
66
+ if m:
67
+ self._finish()
68
+ self.i += 1
69
+ self._label = _STAGE_LABELS.get(m.group(1), m.group(1).replace("_", " "))
70
+ self._begin()
71
+ elif any(w in msg.lower() for w in ("fail", "cancel", "error")):
72
+ if self.tty:
73
+ self.stream.write("\r" + " " * 72 + "\r")
74
+ print(f"· {msg.strip()}", file=self.stream, flush=True)
75
+ elif not self.tty:
76
+ print(f"· {msg.strip()}", file=self.stream, flush=True)
77
+
78
+ def _begin(self) -> None:
79
+ if not self.tty:
80
+ print(f"· [{self.i}/{self.total}] {self._label}…", file=self.stream, flush=True)
81
+ return
82
+ self._start = time.monotonic()
83
+ self._stop = threading.Event()
84
+ self._thread = threading.Thread(target=self._spin, daemon=True)
85
+ self._thread.start()
86
+
87
+ def _spin(self) -> None:
88
+ frames = itertools.cycle(self._FRAMES)
89
+ while not self._stop.is_set():
90
+ el = int(time.monotonic() - self._start)
91
+ self.stream.write(f"\r {next(frames)} [{self.i}/{self.total}] {self._label}… {el}s ")
92
+ self.stream.flush()
93
+ self._stop.wait(0.1)
94
+
95
+ def _finish(self) -> None:
96
+ if not self._thread:
97
+ return
98
+ self._stop.set()
99
+ self._thread.join(timeout=1.0)
100
+ el = int(time.monotonic() - self._start)
101
+ self.stream.write(f"\r ✓ [{self.i}/{self.total}] {self._label} ({el}s){' ' * 12}\n")
102
+ self.stream.flush()
103
+ self._thread = None
104
+
105
+ def done(self) -> None:
106
+ self._finish()
107
+
108
+
109
+ def _run(manifest: Manifest, request: str, ledger_dir: str | None, report_all: bool = False) -> int:
110
+ # Always capture a run: without an explicit --ledger-dir, write the full
111
+ # transcript + per-stage records under a temp dir and tell the user where.
112
+ default_root = ledger_dir is None
113
+ root = ledger_dir or os.path.join(tempfile.gettempdir(), "rapier-runs")
114
+ pipe = manifest.build()
115
+ progress = _Progress(total=len(pipe.stages))
116
+ try:
117
+ env = pipe.run(request, ledger_root=root, log=progress.log)
118
+ finally:
119
+ progress.done()
120
+ # Prefer the composed report if the pipeline produced one.
121
+ out = env.meta.get("report_md") or env.recommendation or ""
122
+ # --report-all: prepend the first half's handoff report (SPARRING is two
123
+ # parts — the Proposer commits an option, the Resolver pressure-tests it).
124
+ proposer_md = env.meta.get("proposer_report_md")
125
+ if report_all and proposer_md:
126
+ out = f"{proposer_md}\n\n{out}"
127
+ print(out)
128
+ run_id = env.meta.get("run_id")
129
+ if run_id:
130
+ run_dir = os.path.join(root, run_id)
131
+ hint = "Full transcript, per-stage records, and this report saved to:"
132
+ if default_root:
133
+ hint = "No --ledger-dir given, so the full transcript + records were saved to:"
134
+ print(f"\n· {hint}\n {run_dir}", file=sys.stderr)
135
+ return 0
136
+
137
+
138
+ def _resolve_request(args) -> str:
139
+ """The decision text — inline via --request, or read from --request-file
140
+ (``-`` for stdin). The mutually-exclusive group guarantees exactly one."""
141
+ path = getattr(args, "request_file", None)
142
+ if path:
143
+ if path == "-":
144
+ return sys.stdin.read()
145
+ with open(path, encoding="utf-8") as fh:
146
+ return fh.read()
147
+ return args.request
148
+
149
+
150
+ def main(argv: list[str] | None = None) -> int:
151
+ parser = argparse.ArgumentParser(
152
+ prog="rapier",
153
+ description="Rapier Runtime — run a SPARRING method from a manifest or preset.",
154
+ epilog="a ResourceForge project · docs: https://rapierruntime.com · "
155
+ "update: pip install -U rapier-runtime",
156
+ )
157
+ parser.add_argument(
158
+ "--version", action="version", version=f"rapier-runtime {__version__}",
159
+ help="print the installed version and exit",
160
+ )
161
+ sub = parser.add_subparsers(dest="cmd", required=True)
162
+
163
+ def add_common(p):
164
+ src = p.add_mutually_exclusive_group(required=True)
165
+ src.add_argument("--request", help="the decision/request text, given inline")
166
+ src.add_argument(
167
+ "--request-file", metavar="PATH",
168
+ help="read the decision/request text from a file (use '-' for stdin) — "
169
+ "friendlier than --request for a multi-line context pack",
170
+ )
171
+ p.add_argument("--ledger-dir", default=None, help="write run artifacts (transcript, report, records) here")
172
+
173
+ for preset in ("spar", "sparring", "proposer"):
174
+ p = sub.add_parser(preset, help=f"run the '{preset}' ceremony preset")
175
+ add_common(p)
176
+ if preset in ("spar", "sparring"): # resolver knobs — no-ops for proposer-only
177
+ p.add_argument(
178
+ "--settle", type=int, default=0, metavar="N",
179
+ help="extra review-and-revise rounds on the recommendation for decision-stability (default 0)",
180
+ )
181
+ p.add_argument(
182
+ "--verify", choices=list(VERIFY_MODES), default="gate",
183
+ help="external-canon citation gate: off | gate (default) | round",
184
+ )
185
+ if preset == "sparring": # the Proposer half only exists in the full ceremony
186
+ p.add_argument(
187
+ "--report-all", action="store_true",
188
+ help="also surface the Proposer report (the committed option + its standing objections) above the Resolver report",
189
+ )
190
+
191
+ run = sub.add_parser("run", help="run a custom manifest")
192
+ run.add_argument("--manifest", required=True, help="path to a pipeline manifest (YAML)")
193
+ add_common(run)
194
+
195
+ sub.add_parser("doctor", help="check which AI vendor keys are configured")
196
+ ip = sub.add_parser("init", help="scaffold a .env.example for vendor keys")
197
+ ip.add_argument("--dir", default=".", help="directory to write .env.example into (default: cwd)")
198
+ sub.add_parser("mcp", help="run the MCP server (stdio) exposing spar/sparring as tools")
199
+
200
+ args = parser.parse_args(argv)
201
+
202
+ if args.cmd == "doctor":
203
+ from .onboarding import doctor_report
204
+
205
+ print(doctor_report())
206
+ return 0
207
+ if args.cmd == "init":
208
+ from .onboarding import init as _init
209
+
210
+ _path, _created, instructions = _init(args.dir)
211
+ print(instructions)
212
+ return 0
213
+ if args.cmd == "mcp":
214
+ from .mcp import serve
215
+
216
+ return serve()
217
+
218
+ try:
219
+ request = _resolve_request(args)
220
+ except OSError as e:
221
+ print(f"rapier: cannot read --request-file: {e}", file=sys.stderr)
222
+ return 2
223
+ if not (request or "").strip():
224
+ print("rapier: the request is empty", file=sys.stderr)
225
+ return 2
226
+
227
+ if args.cmd in ("spar", "sparring", "proposer"):
228
+ from .onboarding import preflight_error
229
+
230
+ err = preflight_error()
231
+ if err: # no vendor keys — fail loudly, not silently
232
+ print(err, file=sys.stderr)
233
+ return 2
234
+ preset = load_preset(
235
+ args.cmd, settle=getattr(args, "settle", 0), verify=getattr(args, "verify", "gate")
236
+ )
237
+ return _run(preset, request, args.ledger_dir, report_all=getattr(args, "report_all", False))
238
+ if args.cmd == "run":
239
+ return _run(Manifest.load(args.manifest), request, args.ledger_dir)
240
+ return 1 # pragma: no cover
241
+
242
+
243
+ if __name__ == "__main__": # pragma: no cover
244
+ raise SystemExit(main())
rapier/convergence.py ADDED
@@ -0,0 +1,93 @@
1
+ """The convergence primitive — the two-agent Generator×Challenger loop, once.
2
+
3
+ SPARK, Pattern Lock, and the Cut are all this loop with a different Challenger
4
+ function (expand / filter / close) and exit goal. Written once here; the phase
5
+ stages configure it.
6
+
7
+ Each round: the Generator produces/extends a payload; the Challenger applies its
8
+ function and raises artifact-cited concerns; both emit ``agree``. The phase
9
+ converges only when **both** agree (the proposer can't close alone). At the cap
10
+ it exits unresolved.
11
+
12
+ G3 (convergence integrity) is built in, not bolted on:
13
+ - *Instrumentation* — every round is recorded; ``no_op`` flags a phase whose
14
+ final payload never moved off the round-1 proposal (a rubber-stamp).
15
+ - *A reopen-capable integrity check* — an optional cross-vendor predicate that,
16
+ on a both-agree, can judge the convergence premature and reopen the phase
17
+ (up to ``reopen_cap``), so convergence is verified, not merely self-reported.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Callable
23
+
24
+
25
+ @dataclass
26
+ class ConvergenceRound:
27
+ index: int
28
+ generator: dict[str, Any]
29
+ challenger: dict[str, Any]
30
+ both_agree: bool
31
+
32
+
33
+ @dataclass
34
+ class ConvergenceResult:
35
+ converged: bool
36
+ payload: Any
37
+ rounds: list[ConvergenceRound] = field(default_factory=list)
38
+ resolved_at: int | None = None
39
+ no_op: bool = False
40
+ integrity_reopened: int = 0
41
+
42
+
43
+ # generator(prev_payload, challenger_concerns) -> {"payload", "agree", "reasoning"}
44
+ Generator = Callable[[Any, Any], dict]
45
+ # challenger(payload) -> {"concerns", "agree", "reasoning"}
46
+ Challenger = Callable[[Any], dict]
47
+
48
+
49
+ def _default_delta(a: Any, b: Any) -> bool:
50
+ return a != b # True == "changed"
51
+
52
+
53
+ def run_convergence(
54
+ generator: Generator,
55
+ challenger: Challenger,
56
+ cap: int,
57
+ *,
58
+ integrity: Callable[[Any, list[ConvergenceRound]], bool] | None = None,
59
+ reopen_cap: int = 1,
60
+ delta: Callable[[Any, Any], bool] | None = None,
61
+ log: Callable[[str], None] = lambda _m: None,
62
+ ) -> ConvergenceResult:
63
+ delta = delta or _default_delta
64
+ rounds: list[ConvergenceRound] = []
65
+ reopened = 0
66
+
67
+ gen = generator(None, None)
68
+ first_payload = gen.get("payload")
69
+
70
+ for i in range(cap):
71
+ chal = challenger(gen.get("payload"))
72
+ both = bool(gen.get("agree")) and bool(chal.get("agree"))
73
+ rounds.append(ConvergenceRound(i + 1, gen, chal, both))
74
+
75
+ if both:
76
+ if integrity is not None and reopened < reopen_cap and not integrity(gen.get("payload"), rounds):
77
+ reopened += 1
78
+ log(f"convergence-integrity: reopened phase (premature) [{reopened}/{reopen_cap}]")
79
+ gen = generator(gen.get("payload"), chal.get("concerns"))
80
+ continue
81
+ no_op = not delta(first_payload, gen.get("payload"))
82
+ return ConvergenceResult(True, gen.get("payload"), rounds, i + 1, no_op, reopened)
83
+
84
+ # Only revise if another challenge round follows. A final, un-challenged
85
+ # revision must NOT become the committed payload: the phase exits on the
86
+ # option the Challenger actually last evaluated, so the committed option
87
+ # and its standing objections (rounds[-1].challenger) refer to the SAME
88
+ # payload — no committed-vs-objection mismatch.
89
+ if i < cap - 1:
90
+ gen = generator(gen.get("payload"), chal.get("concerns"))
91
+
92
+ no_op = not delta(first_payload, gen.get("payload"))
93
+ return ConvergenceResult(False, gen.get("payload"), rounds, None, no_op, reopened)
rapier/envelope.py ADDED
@@ -0,0 +1,66 @@
1
+ """The Envelope — the single typed state object that flows through the pipeline.
2
+
3
+ Every stage reads and writes one Envelope. Nothing else is passed between
4
+ stages. Get this contract right and stages become independently swappable:
5
+ a stage is just ``run(envelope) -> envelope``.
6
+
7
+ The Envelope carries the whole ceremony: the request in, the Proposer's
8
+ evolving option space, the committed option, the Resolver's recommendation and
9
+ trust rider, the accumulated grounding artifacts, and an append-only trace that
10
+ feeds the ledger/audit sink.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Any
17
+
18
+
19
+ @dataclass
20
+ class TraceEntry:
21
+ """One append-only record of what a stage did. Feeds the audit ledger."""
22
+
23
+ stage: str
24
+ kind: str
25
+ summary: str
26
+ data: dict[str, Any] = field(default_factory=dict)
27
+ ts: float = field(default_factory=time.time)
28
+
29
+
30
+ @dataclass
31
+ class Artifact:
32
+ """A checkable grounding claim raised during the ceremony.
33
+
34
+ ``ref`` is the bare checkable token (a CWE id, a DOI, a ``path:line``, a
35
+ ``#3`` pack-fact pointer, a URL). ``verdict`` is set by the verification
36
+ service in later milestones.
37
+ """
38
+
39
+ ref: str
40
+ claim: str
41
+ load_bearing: bool = False
42
+ verdict: str = "unchecked" # unchecked | verified | refuted | unverifiable
43
+
44
+
45
+ @dataclass
46
+ class Envelope:
47
+ """The state passed through every stage of a Rapier pipeline."""
48
+
49
+ request: str
50
+ options: list[str] = field(default_factory=list)
51
+ committed: str | None = None
52
+ recommendation: str | None = None
53
+ trust_rider: dict[str, Any] | None = None
54
+ artifacts: list[Artifact] = field(default_factory=list)
55
+ trace: list[TraceEntry] = field(default_factory=list)
56
+ verdict: str | None = None
57
+ meta: dict[str, Any] = field(default_factory=dict)
58
+
59
+ def add_trace(self, stage: str, kind: str, summary: str, **data: Any) -> "Envelope":
60
+ """Append an audit trace entry and return self (for chaining)."""
61
+ self.trace.append(TraceEntry(stage=stage, kind=kind, summary=summary, data=data))
62
+ return self
63
+
64
+ def to_dict(self) -> dict[str, Any]:
65
+ """Plain-dict view for serialization (redacted before it is persisted)."""
66
+ return asdict(self)
rapier/ledger.py ADDED
@@ -0,0 +1,71 @@
1
+ """The audit sink — persists a run's trace and envelope to a run directory.
2
+
3
+ Every ceremony leaves a re-readable log on disk (the framework's auditability
4
+ discipline). Two security properties, both part of the M0 exit criterion:
5
+
6
+ * Everything written is passed through :func:`rapier.secrets.redact_obj` first.
7
+ * Files and directories are created owner-only (0600 / 0700).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import time
15
+
16
+ from .envelope import Envelope
17
+ from .secrets import redact_obj
18
+
19
+
20
+ def _slug(text: str) -> str:
21
+ text = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
22
+ return text[:40] or "run"
23
+
24
+
25
+ class Ledger:
26
+ def __init__(self, root: str, run_slug: str = "run"):
27
+ stamp = time.strftime("%Y%m%d%H%M%S")
28
+ self.run_dir = os.path.join(root, f"{stamp}-{_slug(run_slug)}")
29
+ os.makedirs(self.run_dir, mode=0o700, exist_ok=True)
30
+ try:
31
+ os.chmod(self.run_dir, 0o700)
32
+ except OSError: # pragma: no cover - platform dependent
33
+ pass
34
+ self.ledger_path = os.path.join(self.run_dir, "ledger.jsonl")
35
+
36
+ def _open_owner_only(self, path: str, append: bool) -> int:
37
+ flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_TRUNC)
38
+ return os.open(path, flags, 0o600)
39
+
40
+ def _write(self, name: str, obj) -> str:
41
+ path = os.path.join(self.run_dir, name)
42
+ payload = json.dumps(redact_obj(obj), indent=2, default=str)
43
+ with os.fdopen(self._open_owner_only(path, append=False), "w") as fh:
44
+ fh.write(payload)
45
+ return path
46
+
47
+ def record_stage(self, stage_name: str, entry: dict) -> None:
48
+ line = json.dumps(redact_obj({"stage": stage_name, **entry}), default=str)
49
+ with os.fdopen(self._open_owner_only(self.ledger_path, append=True), "a") as fh:
50
+ fh.write(line + "\n")
51
+
52
+ def write_text(self, name: str, text: str) -> str:
53
+ """Write a redacted text/markdown artifact (owner-only) to the run dir."""
54
+ path = os.path.join(self.run_dir, name)
55
+ with os.fdopen(self._open_owner_only(path, append=False), "w") as fh:
56
+ fh.write(redact_obj(text))
57
+ return path
58
+
59
+ def write_json(self, name: str, obj) -> str:
60
+ """Write a redacted JSON artifact (owner-only) to the run dir."""
61
+ return self._write(name, obj)
62
+
63
+ def record_transcript(self, event: dict) -> None:
64
+ """Append one verbatim model-call record (redacted) to transcript.jsonl."""
65
+ path = os.path.join(self.run_dir, "transcript.jsonl")
66
+ line = json.dumps(redact_obj(event), default=str)
67
+ with os.fdopen(self._open_owner_only(path, append=True), "a") as fh:
68
+ fh.write(line + "\n")
69
+
70
+ def persist_envelope(self, env: Envelope) -> str:
71
+ return self._write("envelope.json", env.to_dict())
rapier/manifest.py ADDED
@@ -0,0 +1,75 @@
1
+ """The manifest loader — the method as declarative data.
2
+
3
+ A manifest is a YAML file that lists the stages of a pipeline, each with its
4
+ config and its role→model bindings. Editing the manifest changes the *method*
5
+ without touching the engine; that is what makes the method modular and
6
+ upgradeable (swap a model, reorder a stage, add a module).
7
+
8
+ Parsed with ``yaml.safe_load`` — never ``load`` — so a manifest can never
9
+ execute arbitrary Python (threat model: untrusted config / deserialization).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from .models import ModelSpec, Policy
19
+ from .pipeline import Pipeline, StageSpec
20
+
21
+
22
+ @dataclass
23
+ class Manifest:
24
+ name: str
25
+ stages: list[StageSpec]
26
+ policy: Policy | None = None
27
+
28
+ @classmethod
29
+ def load(cls, path: str) -> "Manifest":
30
+ with open(path, encoding="utf-8") as fh:
31
+ data = yaml.safe_load(fh)
32
+ if not isinstance(data, dict):
33
+ raise ValueError("manifest must be a YAML mapping at the top level")
34
+ return cls.from_dict(data)
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict[str, Any]) -> "Manifest":
38
+ name = data.get("name", "pipeline")
39
+ raw_stages = data.get("pipeline")
40
+ if not isinstance(raw_stages, list) or not raw_stages:
41
+ raise ValueError("manifest 'pipeline' must be a non-empty list")
42
+
43
+ stages: list[StageSpec] = []
44
+ for i, entry in enumerate(raw_stages):
45
+ if not isinstance(entry, dict) or "stage" not in entry:
46
+ raise ValueError(f"pipeline[{i}] must be a mapping with a 'stage' key")
47
+ roles: dict[str, ModelSpec] = {}
48
+ for role, binding in (entry.get("roles") or {}).items():
49
+ if "vendor" not in binding or "model" not in binding:
50
+ raise ValueError(
51
+ f"pipeline[{i}].roles.{role} needs both 'vendor' and 'model'"
52
+ )
53
+ roles[role] = ModelSpec(
54
+ vendor=binding["vendor"],
55
+ model=binding["model"],
56
+ prompt_template=binding.get("prompt"),
57
+ max_tokens=binding.get("max_tokens", 4096),
58
+ temperature=binding.get("temperature", 1.0),
59
+ )
60
+ stages.append(
61
+ StageSpec(stage=entry["stage"], config=entry.get("config") or {}, roles=roles)
62
+ )
63
+
64
+ policy = None
65
+ pol = data.get("policy")
66
+ if isinstance(pol, dict):
67
+ policy = Policy(
68
+ vendors=pol.get("vendors"),
69
+ independence=pol.get("independence", "preferred"),
70
+ avoid_jurisdictions=pol.get("avoid_jurisdictions") or [],
71
+ )
72
+ return cls(name=name, stages=stages, policy=policy)
73
+
74
+ def build(self) -> Pipeline:
75
+ return Pipeline.from_manifest(self)
rapier/mcp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Rapier's MCP server (optional — needs the ``mcp`` extra).
2
+
3
+ ``pip install "rapier-runtime[mcp]"`` then run ``rapier mcp``. The SDK is imported
4
+ lazily inside :func:`build_server`, so importing this package (and the rest of the
5
+ CLI) never requires the extra.
6
+ """
7
+ from .server import build_server, serve
8
+
9
+ __all__ = ["build_server", "serve"]