Compare commits

..

42 commits

Author SHA1 Message Date
8d41404b3d 003-gpt-identity: live verification notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 06:27:51 +02:00
dcb97f180f merge: T301 m2-gpt identity login (003) 2026-07-02 06:25:40 +02:00
a77b0dbaef T301: m2-gpt identity login (central credentials consumer) 2026-07-02 06:20:43 +02:00
5dd73eb4cd feat(003-gpt-identity): spec — m2-gpt as credential authority for market-web
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 06:16:24 +02:00
0e6d45ecc1 002-market-web: VERIFICATION — live at market.machinemachine.ai, all SC green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 06:01:07 +02:00
56ce11fdbb web: wallet DTO assertion keeps 'from' (fixture vs DTO shape)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:55:34 +02:00
543d07b6a1 web: economy reads real ledger field names (from_id/to_id); fixtures match live shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:54:53 +02:00
63b39ed39b web: test expects basic auth header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:53:01 +02:00
6531bcc0f1 web: Forgejo basic auth (token-scheme headers 401 on this instance)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:52:20 +02:00
0aeacfd14f wedge(002/T103): web Dockerfile + M2MW_STATIC_DIR (site-packages path fix); merge T101/T102
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:49:09 +02:00
61dee644d7 merge: T102 web frontend SPA 2026-07-02 05:47:26 +02:00
02f1237a90 merge: T101 web backend proxy 2026-07-02 05:47:26 +02:00
b27c5d1981 T102: web frontend SPA (search, listing, wallet, economy, governance) 2026-07-02 05:46:06 +02:00
e77165ed72 T101: web backend proxy (auth, catalog, wallet, governance) + tests 2026-07-02 05:39:58 +02:00
e896a60bc0 feat(002-market-web): spec, plan, tasks, /api contract — the web surface connecting catalog, wallet, governance, install handoff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:34:07 +02:00
8f6fc9d1af wedge: verification gaps updated, run report addendum
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:19:56 +02:00
e244050ff9 wedge: T040/T042-T044 done — M2 Store live on canary (real UI purchase), Scout v0 proposing on canary
- Store: deployed chris-m2o (+gunnar staged); UI install executed a real
  debit->grant->apply, with a truthful auto-refunded failure first
- Scout: Hermes plugin discovered+enabled (39 found/33 enabled), hooks
  manifest fixed, thresholds calibrated on LIVE scores (0.60/0.30 —
  defaults 0.78/0.55 gated out the correct match), proposal recorded to
  outbox, opt-out + rate-cap verified live
- Fleet issues surfaced: chris m2-gpt key 401 (blocks real LLM turns),
  root-owned ~/.claude on chris (fixed via chown)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 05:19:39 +02:00
2e2d335ad6 merge: T042+T043 Solution Scout v0 Hermes plugin 2026-07-02 05:06:41 +02:00
6371ddcc75 T042+T043: Solution Scout v0 Hermes plugin (fail-closed, propose-only) 2026-07-02 05:01:29 +02:00
ab01f39842 wedge(T047): scenario sweep + VERIFICATION.md — A-E verified (B/C live), F/G open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:53:51 +02:00
971b389014 wedge: T045/T046/T041/T037-T039 converged — telemetry, runbook, scout spike (DECISION: Hermes plugin), M2 Store built on cargstore:m2-store
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:52:49 +02:00
a5bb337a9f merge: T041 scout host spike — DECISION: Hermes plugin 2026-07-02 04:49:59 +02:00
88e9a39503 merge: T045 install telemetry write-back 2026-07-02 04:49:59 +02:00
67a65e7f16 T041: scout host spike — decision hermes plugin 2026-07-02 04:35:37 +02:00
1d0ecb8bd9 T045: install telemetry write-back (fire-and-forget) 2026-07-02 04:29:30 +02:00
9ec83bf7f2 wedge(analyze): remediate C1/I1/I2/U1/U2 — rebuild-by-partition-switch, contracts synced to as-built, runbook
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:26:22 +02:00
c52f968f38 wedge: GATE PASSED — T028/T032-T036 done; SC-001 closed commercially
m2bd bought sdjs-operator's lst_mm-pdf-report (40 cr) on m2bd-m2o via
m2-market install: debit->grant->apply exact (60/136/4), license tied to
paying tx, skill invocable, idempotent re-run, refund path proven live,
install telemetry in market:evidence, daily audit snapshots committed to
m2/market-registry (cron 03:10). Run report: ~/.herdr/runs/2026-07-02-*.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:17:09 +02:00
b5c4cd486f wedge: local adapter resolves dest symlinks before home containment check
Found by the T036 gate install on m2bd-m2o: $HOME is a bind of
/agent_home/home/developer; Path.home().resolve() followed the mount while the
expanded dest did not, false-positiving the safety guard. The failed install
correctly auto-refunded (the FR-005 compensation path proved itself live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:15:33 +02:00
2b84874515 wedge(T034): snapshot commits to registry audit/ via Forgejo contents API
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:12:46 +02:00
5d651705ff wedge: adapt catalog clients to REAL memory-api (store/search, agent_id partitions)
- CatalogClient: POST /memory/search, results via metadata.listing; tenant
  visibility filter enforced in the one shared reader client (memory-api
  tenant_id is scalar; deviation recorded in the module docstring)
- indexer MemoryClient: upsert = exists-check + store; drop/delete are WARN
  no-ops (API has no delete) — reindex is an idempotent fill for now
- refund-overdraw decided: compensating refunds may push a seller negative
  (debt, manual reconciliation); data-model amended, property machine updated
- probe doc stored in market:probe partition during API discovery (harmless)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:09:33 +02:00
25b36e9ffb wedge: US2 wave converged — T027 publish cmd, T029/T030 seeds (schema-valid, scrubbed); T031 blocked on operator
t031 worker found ZERO real competitor-scan evidence and correctly built
nothing (constitution III). Recommended substitute: Parlobyg email-triage
(real production run 2026-03-15..04-04) — tenant-derived, requires operator
owner-initiation + double scrub (constitution IV). Recorded as open question.
Tenant-named hosts scrubbed from agent-scaffold evidence. 29 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:51:27 +02:00
51694acc64 chore: drop remaining tracked pyc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:50:35 +02:00
3886ee8480 merge: seed-agent-scaffold (US2) 2026-07-02 03:50:25 +02:00
5e2dd7aac3 merge: seed-mmpdf (US2) 2026-07-02 03:50:25 +02:00
178f373fe1 merge: publish-cmd (US2, pycache conflicts dropped) 2026-07-02 03:50:25 +02:00
91ca883ccb chore: untrack __pycache__/pyc/uv.lock, extend gitignore
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:50:25 +02:00
362fd2e5b3 T030: seed solution agent-scaffold 2026-07-02 03:47:09 +02:00
430061c0ff T029: seed solution mm-pdf-report 2026-07-02 03:42:12 +02:00
82393ccdc5 T027: publish command (validate -> branch -> release -> PR)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 03:38:59 +02:00
83cda680d2 wedge: T026 integration tests (real ledger via uvicorn, real bundle, temp HOME)
Written inline after two headless workers died on API errors. Tests caught a
real cross-slice bug: ledger grants stored the full install ref as listing_id,
breaking GET /license/{op}/{listing} — fixed by parsing the listing prefix per
the ref convention in contracts/ledger-api.md. Also pinned the indexer test's
schema path (venv-installed package resolved outside the repo) — its earlier
pass was vacuous without jsonschema installed. 27 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:34:21 +02:00
6fa1bc8e29 ci: verify push-triggered deploy 2026-07-02 03:26:19 +02:00
f9108902d9 wedge: T014 done — m2-ledger deployed on Coolify with push-triggered CI/CD
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:24:24 +02:00
112 changed files with 8152 additions and 1644 deletions

9
.gitignore vendored
View file

@ -1 +1,10 @@
.m2herd/
__pycache__/
*.pyc
.venv/
uv.lock
.pytest_cache/
web/frontend/node_modules/
web/frontend/dist/

View file

@ -1 +1 @@
{"feature_directory":"specs/001-market-first-wedge"}
{"feature_directory":"specs/002-market-web"}

View file

@ -151,6 +151,11 @@ class LocalApplyAdapter:
if not dest.is_absolute():
dest = home / dest
dest = Path(os.path.normpath(str(dest)))
# Resolve symlinked/bind-mounted ancestors so the containment check
# compares real paths: on m2o desktops $HOME (/home/developer) is a
# mount of /agent_home/home/developer, and Path.home().resolve()
# already followed it — an unresolved dest would false-positive here.
dest = dest.resolve()
if dest != home and home not in dest.parents:
raise ValueError(

View file

@ -1,22 +1,32 @@
"""Client for the `market:catalog` semantic index (memory-api).
Query contract: specs/001-market-first-wedge/contracts/catalog-index.md
Adapted 2026-07-02 to the REAL memory-api surface (agent.memory.system):
`POST /memory/search` with `{query, agent_id, limit}` the partition is the
`agent_id` namespace, and each result carries the listing in `metadata`.
DEVIATION from catalog-index.md (recorded): memory-api's server-side filter is a
scalar `tenant_id`, but listings carry a `tenant_visibility` ARRAY. Until the
memory-api grows array filters (fedlearn T13 follow-up), the visibility filter
`tenant_visibility {caller tenant | "*"}` is enforced HERE, in the one shared
reader client that every surface (CLI, Store, Scout) goes through never in
per-surface code.
"""
from __future__ import annotations
import os
import httpx
PARTITION = "market:catalog"
# Overridable so a full catalog rebuild can land in a fresh partition and readers
# switch by config (constitution II rebuildability; see docs/runbook.md).
PARTITION = os.environ.get("M2_MARKET_CATALOG_PARTITION", "market:catalog")
class CatalogClient:
"""Tenant-scoped semantic search over the market:catalog partition.
`tenant` is always sent on every request the server enforces the
`tenant_visibility {caller tenant | "*"}` filter (FR-007); this client
never relies on filtering results client-side.
"""
"""Tenant-scoped semantic search over the market:catalog partition."""
def __init__(
self,
@ -24,8 +34,8 @@ class CatalogClient:
api_key: str,
tenant: str,
*,
search_path: str = "/search",
timeout: float = 10.0,
search_path: str = "/memory/search",
timeout: float = 30.0,
transport: httpx.BaseTransport | None = None,
) -> None:
self.tenant = tenant
@ -46,95 +56,93 @@ class CatalogClient:
def __exit__(self, *exc_info: object) -> None:
self.close()
def _visible(self, listing: dict) -> bool:
visibility = listing.get("tenant_visibility") or []
return "*" in visibility or self.tenant in visibility
def search(self, query_text: str, limit: int = 10) -> list[dict]:
"""Semantic query against market:catalog, filtered server-side to `self.tenant`.
"""Semantic query against market:catalog, visibility-filtered to `self.tenant`.
Returns a list of `{listing_id, score, listing}` ordered by hybrid score.
"""
response = self._client.post(
self.search_path,
json={
"query_text": query_text,
"partition": PARTITION,
"tenant": self.tenant,
"limit": limit,
"query": query_text,
"agent_id": PARTITION,
"memory_types": ["semantic"],
# Overfetch so the visibility filter doesn't starve the result set.
"limit": max(limit * 3, limit),
},
)
response.raise_for_status()
return self._extract_items(response.json())
items = []
for result in response.json().get("results", []):
meta = result.get("metadata") or {}
listing = meta.get("listing")
listing_id = meta.get("listing_id")
if not listing or not listing_id:
continue
if not self._visible(listing):
continue
items.append(
{"listing_id": listing_id, "score": result.get("score"), "listing": listing}
)
if len(items) >= limit:
break
return items
def get(self, listing_id: str) -> dict | None:
"""Fetch one listing's payload by id, or None if not found/visible.
Convenience wrapper: searches by `listing_id` (still tenant-scoped
the search endpoint is the only server-side-filtered fallback) and
picks the matching item out of the results.
"""
for item in self.search(listing_id, limit=1):
"""Fetch one listing's payload by id, or None if not found/visible."""
for item in self.search(listing_id, limit=10):
if item.get("listing_id") == listing_id:
return item.get("listing")
return None
@staticmethod
def _extract_items(payload: object) -> list[dict]:
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
items = payload.get("items")
if isinstance(items, list):
return items
return []
if __name__ == "__main__":
# Inline verification: assert the request body sent to memory-api carries
# `partition` and `tenant` (server-side filter contract, catalog-index.md).
captured: dict = {}
# Inline verification against the real response shape.
def _handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["headers"] = dict(request.headers)
captured["body"] = httpx.Request("POST", request.url, content=request.content).content
import json as _json
body = _json.loads(request.content)
captured["json"] = body
assert body["agent_id"] == PARTITION, body
assert body["query"], body
return httpx.Response(
200,
json={
"items": [
"results": [
{
"listing_id": "sol_pdf_export_v1",
"id": "mem-1",
"content": "PDF Export ...",
"score": 0.87,
"listing": {"name": "PDF Export", "tenant_visibility": ["*"]},
}
]
"metadata": {
"listing_id": "lst_pdf-export",
"listing": {"name": "PDF Export", "tenant_visibility": ["*"]},
},
},
{
"id": "mem-2",
"content": "tenant-only thing",
"score": 0.5,
"metadata": {
"listing_id": "lst_private",
"listing": {"name": "Private", "tenant_visibility": ["other-tenant"]},
},
},
],
"routing": {},
},
)
client = CatalogClient(
base_url="https://memory.machinemachine.ai",
api_key="mem_test_key",
tenant="machine-machine",
tenant="m2-core",
transport=httpx.MockTransport(_handler),
)
results = client.search("branded pdf report", limit=5)
assert captured["json"]["partition"] == PARTITION, captured["json"]
assert captured["json"]["tenant"] == "machine-machine", captured["json"]
assert captured["json"]["query_text"] == "branded pdf report"
assert captured["json"]["limit"] == 5
assert captured["headers"]["x-api-key"] == "mem_test_key"
assert results[0]["listing_id"] == "sol_pdf_export_v1"
fetched = client.get("sol_pdf_export_v1")
assert fetched == {"name": "PDF Export", "tenant_visibility": ["*"]}
missing = client.get("does_not_exist")
assert missing is None
print("OK: request body ->", captured["json"])
print("OK: search() ->", results)
print("OK: get('sol_pdf_export_v1') ->", fetched)
print("OK: get('does_not_exist') ->", missing)
assert [r["listing_id"] for r in results] == ["lst_pdf-export"], results # filtered
assert client.get("lst_pdf-export") == {"name": "PDF Export", "tenant_visibility": ["*"]}
assert client.get("lst_private") is None # invisible to this tenant
print("OK:", results)

View file

@ -6,6 +6,7 @@ import socket
import sys
import tarfile
import tempfile
import time
import uuid
from pathlib import Path
@ -18,7 +19,9 @@ from m2_market.catalog import CatalogClient
from m2_market.config import Config, ConfigError, load_config
from m2_market.ledger import InsufficientFunds, LedgerClient
from m2_market.output import emit_json, format_price, render_kv, render_table, search_results_table
from m2_market.publish import PublishValidationError, publish_bundle, validate_bundle
from m2_market.registry import RegistryClient, RegistryError
from m2_market.telemetry import post_install_outcome
# Exit-code map (contracts/cli.md).
EXIT_OK = 0 # ok / no-op
@ -200,6 +203,7 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
ref = f"{listing_id}:{socket.gethostname()}:{uuid.uuid4()}"
seller = listing.get("seller") or solution.get("seller")
amount = price.get("amount")
started = time.monotonic()
try:
grant = ledger.install(buyer=config.operator_id, seller=seller, amount=amount, ref=ref)
@ -220,6 +224,15 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
bundle_root = _extract_bundle(bundle_path, tmp_dir / "extracted")
except (RegistryError, OSError, tarfile.TarError) as exc:
ledger.refund(ref)
post_install_outcome(
config,
listing_id,
seller,
amount,
ref,
"fetch_failed_refunded",
time.monotonic() - started,
)
click.echo(
f"m2-market install: bundle fetch/verify failed, refunded: {exc}",
err=True,
@ -243,6 +256,15 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
return
except Exception as exc:
ledger.refund(ref)
post_install_outcome(
config,
listing_id,
seller,
amount,
ref,
"apply_failed_refunded",
time.monotonic() - started,
)
click.echo(
f"m2-market install: apply failed, refunded: {exc}\n"
"Remediation: inspect the recipe's post-install checks, fix the "
@ -259,6 +281,16 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
changeset.changeset_hash,
"applied",
)
post_install_outcome(
config,
listing_id,
seller,
amount,
ref,
"applied",
time.monotonic() - started,
grant_id=grant.get("grant_id"),
)
_print_entrypoint_hint(json_output, solution, {"listing_id": listing_id, "noop": False})
@ -304,8 +336,25 @@ def wallet(ctx: click.Context, operator: str | None) -> None:
@click.argument("bundle_dir")
@click.pass_context
def publish(ctx: click.Context, bundle_dir: str) -> None:
"""Validate a bundle and open a listing PR on the registry."""
_not_implemented(ctx, "publish")
"""Validate a bundle and open a listing PR on the registry (Story 2 step 1-2)."""
config: Config = ctx.obj["config"]
json_output = ctx.obj["json"]
try:
solution, listing = validate_bundle(Path(bundle_dir))
except PublishValidationError as exc:
for line in exc.errors:
click.echo(f"m2-market publish: {line}", err=True)
ctx.exit(EXIT_VALIDATION)
return
with RegistryClient(config.forgejo_url, config.forgejo_token) as registry:
pr_url = publish_bundle(Path(bundle_dir), solution, listing, registry)
if json_output:
emit_json({"status": "ok", "pr_url": pr_url})
else:
click.echo(pr_url)
@main.command()

View file

@ -0,0 +1,181 @@
"""Publish command implementation (contracts/cli.md publish, contracts/registry-layout.md).
Validates a bundle against the frozen schemas (schemas/*.json), packages
payload/+recipe.yaml+solution.json into a release tarball, and runs the
publish protocol against the registry: branch -> commit -> release -> PR.
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import tarfile
import tempfile
from pathlib import Path
from jsonschema import Draft202012Validator
from m2_market.registry import RegistryClient
class PublishValidationError(Exception):
"""Raised when the bundle fails schema validation (contracts/cli.md exit 5)."""
def __init__(self, errors: list[str]):
super().__init__("; ".join(errors))
self.errors = errors
def schemas_dir() -> Path:
"""Resolve the frozen schemas dir, honoring the M2_MARKET_SCHEMAS_DIR override."""
override = os.environ.get("M2_MARKET_SCHEMAS_DIR")
if override:
return Path(override)
return Path(__file__).resolve().parents[3] / "schemas"
def _load_json(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
return json.load(f)
def _validate(instance: dict, schema_path: Path, label: str) -> list[str]:
schema = _load_json(schema_path)
validator = Draft202012Validator(schema)
errors = []
for err in sorted(validator.iter_errors(instance), key=lambda e: list(map(str, e.path))):
loc = "/".join(str(p) for p in err.path) or "<root>"
errors.append(f"{label}: {loc}: {err.message}")
return errors
def validate_bundle(bundle_dir: Path) -> tuple[dict, dict]:
"""Validate BUNDLE_DIR/solution.json + listing.json against the frozen schemas.
Returns (solution, listing) on success; raises PublishValidationError
(one message per violation, including tenant-firewall rules encoded in
solution.schema.json's if/else) otherwise.
"""
solution_path = bundle_dir / "solution.json"
listing_path = bundle_dir / "listing.json"
errors: list[str] = []
solution: dict | None = None
listing: dict | None = None
try:
solution = _load_json(solution_path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"solution.json: {exc}")
try:
listing = _load_json(listing_path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"listing.json: {exc}")
if errors:
raise PublishValidationError(errors)
schemas = schemas_dir()
errors += _validate(solution, schemas / "solution.schema.json", "solution.json")
errors += _validate(listing, schemas / "listing.schema.json", "listing.json")
if errors:
raise PublishValidationError(errors)
return solution, listing
def build_bundle_tarball(bundle_dir: Path, solution: dict, dest_path: Path) -> str:
"""Tar payload/+recipe.yaml+solution.json into dest_path; return its sha256:<hex> hash."""
payload_dir = bundle_dir / "payload"
recipe_path = bundle_dir / "recipe.yaml"
with tarfile.open(dest_path, "w:gz") as tar:
if payload_dir.is_dir():
tar.add(payload_dir, arcname="payload")
if recipe_path.is_file():
tar.add(recipe_path, arcname="recipe.yaml")
solution_bytes = json.dumps(solution, indent=2, sort_keys=True).encode("utf-8")
info = tarfile.TarInfo(name="solution.json")
info.size = len(solution_bytes)
tar.addfile(info, io.BytesIO(solution_bytes))
digest = hashlib.sha256()
with dest_path.open("rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
digest.update(chunk)
return f"sha256:{digest.hexdigest()}"
def render_pr_body(solution: dict, listing: dict) -> str:
"""PR template body (registry-layout.md publish protocol step 2)."""
permissions = solution.get("permissions") or []
permissions_lines = "\n".join(f"- `{perm}`" for perm in permissions) or "- (none)"
price = solution.get("price") or {}
tenant_scope = solution.get("tenant_scope", "-")
body = (
f"## Evidence summary\n{listing.get('evidence_summary', '-')}\n\n"
f"## Price\n{price.get('amount', '-')} {price.get('currency', '-')}\n\n"
f"## Permissions\n{permissions_lines}\n\n"
f"## Rollback\nRe-run `m2-market install` after uninstall; the apply adapter "
f"restores pre-install backups (contracts/apply-adapter.md).\n"
)
if tenant_scope != "m2-core":
owner_initiated = bool(solution.get("owner_initiated"))
double_scrubbed = bool((solution.get("scrub_status") or {}).get("double_scrubbed"))
body += (
"\n## Tenant-derived attestations\n"
f"- [{'x' if owner_initiated else ' '}] owner_initiated\n"
f"- [{'x' if double_scrubbed else ' '}] double_scrubbed\n"
)
return body
def publish_bundle(bundle_dir: Path, solution: dict, listing: dict, registry: RegistryClient) -> str:
"""Run the publish protocol (branch -> commit -> release -> PR); returns the PR URL."""
listing_id = listing["listing_id"]
branch = f"listing/{listing_id}"
install_ref = listing["install_ref"]
with tempfile.TemporaryDirectory(prefix="m2-market-publish-") as tmp_dir_str:
tarball_path = Path(tmp_dir_str) / f"{install_ref}.tar.gz"
content_hash = build_bundle_tarball(bundle_dir, solution, tarball_path)
solution = dict(solution)
solution["content_hash"] = content_hash
registry.create_branch(branch)
registry.create_file(
f"listings/{listing_id}/listing.json",
json.dumps(listing, indent=2, sort_keys=True).encode("utf-8") + b"\n",
message=f"listing({listing_id}): add listing.json",
branch=branch,
)
registry.create_file(
f"listings/{listing_id}/solution.json",
json.dumps(solution, indent=2, sort_keys=True).encode("utf-8") + b"\n",
message=f"listing({listing_id}): add solution.json",
branch=branch,
)
release = registry.create_release(
tag_name=install_ref,
target_commitish=branch,
name=install_ref,
body=f"Bundle release for {listing_id}",
)
registry.upload_release_asset(release["id"], tarball_path.name, tarball_path.read_bytes())
pr = registry.create_pull_request(
title=f"listing: {listing.get('name', listing_id)} ({listing_id})",
body=render_pr_body(solution, listing),
head=branch,
base="main",
)
return pr["html_url"]

View file

@ -84,6 +84,56 @@ class RegistryClient:
return dest_path
def create_branch(self, new_branch: str, old_branch: str = "main") -> dict:
resp = self._client.post(
self._repo_api("/branches"),
json={"new_branch_name": new_branch, "old_branch_name": old_branch},
)
resp.raise_for_status()
return resp.json()
def create_file(self, repo_path: str, content: bytes, message: str, branch: str) -> dict:
resp = self._client.post(
self._repo_api(f"/contents/{repo_path}"),
json={
"content": base64.b64encode(content).decode("ascii"),
"message": message,
"branch": branch,
},
)
resp.raise_for_status()
return resp.json()
def create_release(self, tag_name: str, target_commitish: str, name: str, body: str) -> dict:
resp = self._client.post(
self._repo_api("/releases"),
json={
"tag_name": tag_name,
"target_commitish": target_commitish,
"name": name,
"body": body,
},
)
resp.raise_for_status()
return resp.json()
def upload_release_asset(self, release_id: int, filename: str, content: bytes) -> dict:
resp = self._client.post(
self._repo_api(f"/releases/{release_id}/assets"),
params={"name": filename},
files={"attachment": (filename, content, "application/gzip")},
)
resp.raise_for_status()
return resp.json()
def create_pull_request(self, title: str, body: str, head: str, base: str = "main") -> dict:
resp = self._client.post(
self._repo_api("/pulls"),
json={"title": title, "body": body, "head": head, "base": base},
)
resp.raise_for_status()
return resp.json()
@staticmethod
def verify_bundle(path: str | Path, content_hash: str) -> bool:
algo, _, expected = content_hash.partition(":")

View file

@ -0,0 +1,58 @@
"""Install outcome telemetry write-back (contracts/catalog-index.md §Telemetry write-back).
Fire-and-forget: writes an episodic memory into the `market:evidence` partition so
listings accrue install evidence. Telemetry must never break an install any
failure is swallowed after printing one warning line to stderr.
"""
from __future__ import annotations
import sys
import httpx
from m2_market.config import Config
PARTITION = "market:evidence"
def post_install_outcome(
config: Config,
listing_id: str,
seller: str | None,
amount,
ref: str,
outcome: str,
wall_seconds: float,
grant_id: str | None = None,
) -> None:
"""Best-effort write of an install outcome to memory-api. Never raises."""
try:
with httpx.Client(
base_url=config.memory_api_url,
headers={"X-API-Key": config.memory_api_key},
timeout=10.0,
) as client:
response = client.post(
"/memory/store",
json={
"content": f"install {outcome}: {listing_id}",
"agent_id": PARTITION,
"memory_type": "episodic",
"importance": 0.8,
"source": "m2-market-cli",
"metadata": {
"listing_id": listing_id,
"buyer": config.operator_id,
"seller": seller,
"amount": amount,
"outcome": outcome,
"ref": ref,
"wall_seconds": wall_seconds,
"grant_id": grant_id,
},
},
)
response.raise_for_status()
except Exception as exc: # noqa: BLE001 - telemetry must never break an install
print(f"m2-market: telemetry write-back failed (ignored): {exc}", file=sys.stderr)

View file

@ -85,6 +85,7 @@ class FakeState:
self.refund_calls = 0
self.license_exists = False
self.insufficient_funds = False
self.telemetry_calls = []
def _b64_json(payload: dict) -> str:
@ -146,11 +147,29 @@ def make_handler(state: FakeState):
if path == "/download/bundle.tar.gz":
return httpx.Response(200, content=BUNDLE_BYTES)
if host == "catalog.test" and path == "/search":
if host == "catalog.test" and path == "/memory/search":
body = json.loads(request.content)
if body["query_text"] == LISTING_ID:
return httpx.Response(200, json={"items": [{"listing_id": LISTING_ID, "score": 0.9, "listing": LISTING}]})
return httpx.Response(200, json={"items": [{"listing_id": LISTING_ID, "score": 0.8, "listing": LISTING}]})
assert body["agent_id"] == "market:catalog", body
score = 0.9 if body["query"] == LISTING_ID else 0.8
return httpx.Response(
200,
json={
"results": [
{
"id": "mem-1",
"score": score,
"metadata": {"listing_id": LISTING_ID, "listing": LISTING},
}
],
"routing": {},
},
)
if host == "catalog.test" and path == "/memory/store":
body = json.loads(request.content)
assert body["agent_id"] == "market:evidence", body
state.telemetry_calls.append(body)
return httpx.Response(200, json={"id": "mem-telemetry-1"})
raise AssertionError(f"unexpected request: {request.method} {request.url}")
@ -243,6 +262,10 @@ def test_funded_install_happy_path(fake_backend, config_env, monkeypatch):
assert calls.plan_calls == 1
assert calls.apply_calls == 1
assert calls.verify_calls == 1
assert len(fake_backend.telemetry_calls) == 1
telemetry = fake_backend.telemetry_calls[0]
assert telemetry["metadata"]["outcome"] == "applied"
assert telemetry["metadata"]["listing_id"] == LISTING_ID
def test_insufficient_funds_no_adapter_call(fake_backend, config_env, monkeypatch):

View file

@ -0,0 +1,248 @@
"""T026 — CLI integration tests: REAL ledger (uvicorn on an ephemeral port, tmp sqlite),
fixture catalog/registry, REAL temp bundle applied by the local adapter into a temp HOME.
quickstart.md Scenarios B/C at test scale: funded install end-to-end, insufficient funds,
idempotent re-run, apply-failure refund + rollback.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import socket
import tarfile
import threading
import time
from pathlib import Path
import httpx
import pytest
import uvicorn
from click.testing import CliRunner
SERVICE_KEY = "svc-test-key"
ADMIN_KEY = "adm-test-key"
LISTING_ID = "lst_mm_pdf_report"
PRICE = 40
# --- real ledger server -------------------------------------------------------------
@pytest.fixture()
def ledger_url(tmp_path_factory, monkeypatch):
db = tmp_path_factory.mktemp("ledger") / "ledger.db"
monkeypatch.setenv("LEDGER_DB_PATH", str(db))
monkeypatch.setenv("LEDGER_SERVICE_KEYS", SERVICE_KEY)
monkeypatch.setenv("LEDGER_ADMIN_KEYS", ADMIN_KEY)
# Import AFTER env is set so module-level defaults read the test values.
from m2_ledger.api import app # noqa: PLC0415
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
url = f"http://127.0.0.1:{port}"
for _ in range(100):
try:
if httpx.get(url + "/health", timeout=1).status_code == 200:
break
except httpx.HTTPError:
time.sleep(0.05)
else:
pytest.fail("ledger server did not come up")
yield url
server.should_exit = True
thread.join(timeout=5)
def _ledger_get(url: str, path: str, key: str = SERVICE_KEY) -> dict:
r = httpx.get(url + path, headers={"X-API-Key": key})
r.raise_for_status()
return r.json()
def _grant(url: str, operator: str, amount: int = 100) -> None:
httpx.post(
url + "/grant/starter",
json={"operator_id": operator, "amount": amount},
headers={"X-API-Key": ADMIN_KEY},
).raise_for_status()
def _tx_count(url: str) -> int:
return len(_ledger_get(url, "/tx")["transactions"])
# --- temp bundle + fixtures ---------------------------------------------------------
def _build_bundle(root: Path, failing_check: bool = False) -> tuple[Path, str]:
"""Real bundle tarball; returns (tar_path, content_hash)."""
src = root / "bundle-src"
(src / "payload").mkdir(parents=True)
(src / "payload" / "hello.txt").write_text("hello from m2-market test bundle\n")
check_cmd = "false" if failing_check else "test -f ~/m2-market-test/hello.txt"
(src / "recipe.yaml").write_text(
"targets:\n"
" - src: hello.txt\n"
" dest: ~/m2-market-test/hello.txt\n"
"checks:\n"
f" - cmd: {check_cmd}\n"
" expect_exit: 0\n"
"entrypoint: cat ~/m2-market-test/hello.txt\n"
)
(src / "solution.json").write_text(json.dumps({"solution_id": "sol_test"}))
tar_path = root / "bundle.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
tar.add(src, arcname="bundle")
digest = hashlib.sha256(tar_path.read_bytes()).hexdigest()
return tar_path, f"sha256:{digest}"
@pytest.fixture()
def env(tmp_path, monkeypatch, ledger_url):
"""Isolated HOME/state/config + fixture catalog/registry pointed at the real ledger."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("M2_MARKET_STATE", str(tmp_path / "state.json"))
bundle_path, content_hash = _build_bundle(tmp_path)
config = tmp_path / "config.toml"
config.write_text(
f'operator_id = "m2bd"\n'
f'tenant = "m2-core"\n'
f'ledger_url = "{ledger_url}"\n'
f'ledger_api_key = "{SERVICE_KEY}"\n'
f'memory_api_url = "http://catalog.invalid"\n'
f'memory_api_key = "k"\n'
f'forgejo_url = "http://registry.invalid"\n'
f'forgejo_token = "t"\n'
f'apply_adapter = "local"\n'
)
monkeypatch.setenv("M2_MARKET_CONFIG", str(config))
listing = {
"listing_id": LISTING_ID,
"name": "MM PDF Report",
"summary": "branded pdf reports",
"price": {"amount": PRICE, "currency": "m2cr", "model": "fixed"},
"seller": "sdjs-operator",
"solution_version": "1.0.0",
"install_ref": f"{LISTING_ID}-v1.0.0",
}
solution = {
"solution_id": "sol_test",
"content_hash": content_hash,
"permissions": ["write ~/m2-market-test"],
"deployment": {"entrypoint": "cat ~/m2-market-test/hello.txt"},
}
from m2_market import cli as cli_mod # noqa: PLC0415
monkeypatch.setattr(cli_mod.CatalogClient, "get", lambda self, lid: dict(listing) if lid == LISTING_ID else None)
monkeypatch.setattr(cli_mod.RegistryClient, "get_solution", lambda self, lid: dict(solution))
monkeypatch.setattr(
cli_mod.RegistryClient,
"download_bundle",
lambda self, ref, dest: (Path(dest).mkdir(parents=True, exist_ok=True), shutil.copy(bundle_path, Path(dest) / "bundle.tar.gz"))[1] and Path(dest) / "bundle.tar.gz",
)
return {
"home": home,
"ledger_url": ledger_url,
"bundle_path": bundle_path,
"listing": listing,
"solution": solution,
"tmp": tmp_path,
"monkeypatch": monkeypatch,
"cli_mod": cli_mod,
}
def _run(args, **kwargs):
from m2_market.cli import main # noqa: PLC0415
return CliRunner().invoke(main, args, catch_exceptions=False, **kwargs)
# --- scenarios ----------------------------------------------------------------------
def test_funded_install_end_to_end(env):
_grant(env["ledger_url"], "m2bd", 100)
result = _run(["install", LISTING_ID, "--yes"])
assert result.exit_code == 0, result.output
# ledger rows exact: net + cut
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == 100 - PRICE
assert _ledger_get(env["ledger_url"], "/balance/sdjs-operator")["balance"] == 36
assert _ledger_get(env["ledger_url"], "/balance/platform")["balance"] == 4
# grant exists
lic = httpx.get(
env["ledger_url"] + f"/license/m2bd/{LISTING_ID}",
headers={"X-API-Key": SERVICE_KEY},
)
assert lic.status_code == 200
# file placed into the temp HOME by the local adapter
assert (env["home"] / "m2-market-test" / "hello.txt").exists()
# state.json updated
state = json.loads(Path(os.environ["M2_MARKET_STATE"]).read_text())
assert state["installs"][LISTING_ID]["status"] == "applied"
# entrypoint hint printed
assert "cat ~/m2-market-test/hello.txt" in result.output
def test_insufficient_funds_exit_3_nothing_written(env):
# no grant: wallet is empty
before = _tx_count(env["ledger_url"])
result = _run(["install", LISTING_ID, "--yes"])
assert result.exit_code == 3
assert _tx_count(env["ledger_url"]) == before
assert not (env["home"] / "m2-market-test").exists()
def test_idempotent_rerun_no_second_debit(env):
_grant(env["ledger_url"], "m2bd", 100)
assert _run(["install", LISTING_ID, "--yes"]).exit_code == 0
txs_after_first = _tx_count(env["ledger_url"])
rerun = _run(["install", LISTING_ID, "--yes"])
assert rerun.exit_code == 0
assert "no-op" in rerun.output
assert _tx_count(env["ledger_url"]) == txs_after_first
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == 100 - PRICE
def test_apply_failure_refunds_and_rolls_back(env):
_grant(env["ledger_url"], "m2bd", 100)
# swap in a bundle whose post-install check fails
bad_bundle, bad_hash = _build_bundle(env["tmp"] / "bad", failing_check=True)
env["solution"]["content_hash"] = bad_hash
env["monkeypatch"].setattr(
env["cli_mod"].RegistryClient,
"download_bundle",
lambda self, ref, dest: (Path(dest).mkdir(parents=True, exist_ok=True), shutil.copy(bad_bundle, Path(dest) / "b.tar.gz"))[1] and Path(dest) / "b.tar.gz",
)
before = _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"]
result = _run(["install", LISTING_ID, "--yes"])
assert result.exit_code == 4
assert "refund" in result.output.lower()
# refund compensates exactly
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == before
refunds = _ledger_get(env["ledger_url"], "/tx?reason=refund")["transactions"]
assert refunds, "compensating refund rows expected"
# rollback removed placed files
assert not (env["home"] / "m2-market-test" / "hello.txt").exists()

161
cli/tests/test_publish.py Normal file
View file

@ -0,0 +1,161 @@
"""Inline verification for the publish CLI command (contracts/cli.md, registry-layout.md).
Covers: (1) invalid bundle -> schema errors printed, exit 5, before any registry
call; (2) valid bundle -> happy-path branch/commit/release/PR sequence against
a MockTransport fake of the Forgejo API.
"""
from __future__ import annotations
import json
import httpx
import pytest
from click.testing import CliRunner
import m2_market.cli as cli_mod
LISTING_ID = "lst_mm-pdf-report"
SOLUTION_ID = "sol_mm-pdf-report"
INSTALL_REF = "lst_mm-pdf-report-v1.0.0"
SELLER = "sdjs-operator"
VALID_SOLUTION = {
"schema_version": "m2.solution.v1",
"solution_id": SOLUTION_ID,
"name": "MM PDF Branded Report",
"summary": "Generate branded PDF reports from Markdown",
"intent": "Produce publish-ready branded PDF output without manual design work",
"deployment": {
"recipe_ref": "recipes/mm-pdf-report/recipe.yaml",
"entrypoint": "skills/mm-pdf/render.py",
"verify_command": "python skills/mm-pdf/render.py --self-test",
},
"applicability": {"image_classes": ["desktop-agent"]},
"tenant_scope": "m2-core",
"evidence": [{"source": "session:2026-05-11-mm-pdf-branded-report"}],
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"price": {"amount": 500, "currency": "m2cr", "model": "fixed"},
"seller": SELLER,
"license": {"terms": "internal", "major_version_coverage": True},
"permissions": [{"scope": "filesystem:write", "path": "/tmp/mm-pdf-output"}],
}
VALID_LISTING = {
"schema_version": "m2.listing.v1",
"listing_id": LISTING_ID,
"solution_id": SOLUTION_ID,
"solution_version": "1.0.0",
"inventory_type": "solution",
"name": "MM PDF Branded Report",
"summary": "Generate branded PDF reports from Markdown",
"category": "reporting",
"price": {"amount": 500, "currency": "m2cr", "model": "fixed"},
"seller": SELLER,
"evidence_summary": "Generated branded PDF report; operator confirmed styling matched brand deck.",
"tenant_visibility": ["*"],
"stats": {"installs": 0, "proposals_shown": 0, "proposals_accepted": 0},
"status": "draft",
"install_ref": INSTALL_REF,
}
def _write_bundle(bundle_dir, solution=None, listing=None):
bundle_dir.mkdir(parents=True, exist_ok=True)
(bundle_dir / "solution.json").write_text(json.dumps(solution if solution is not None else VALID_SOLUTION))
(bundle_dir / "listing.json").write_text(json.dumps(listing if listing is not None else VALID_LISTING))
payload_dir = bundle_dir / "payload"
payload_dir.mkdir(exist_ok=True)
(payload_dir / "report.md").write_text("# hello\n")
(bundle_dir / "recipe.yaml").write_text("targets: []\nchecks: []\n")
return bundle_dir
@pytest.fixture
def config_env(tmp_path, monkeypatch):
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
operator_id = "buyer1"
tenant = "test-tenant"
ledger_url = "https://ledger.test"
ledger_api_key = "lk"
memory_api_url = "https://catalog.test"
memory_api_key = "mk"
forgejo_url = "https://registry.test"
forgejo_token = "rt"
"""
)
monkeypatch.setenv("M2_MARKET_CONFIG", str(config_path))
monkeypatch.setenv("M2_MARKET_SCHEMAS_DIR", str(_repo_schemas_dir()))
return config_path
def _repo_schemas_dir():
from pathlib import Path
return Path(__file__).resolve().parents[2] / "schemas"
def test_invalid_bundle_fails_validation_before_any_network_call(tmp_path, config_env, monkeypatch):
def fail_if_called(*args, **kwargs):
raise AssertionError("registry must not be contacted when validation fails")
monkeypatch.setattr(httpx.Client, "post", fail_if_called)
bad_solution = dict(VALID_SOLUTION)
bad_solution["evidence"] = [] # violates evidence minItems (Principle III evidence-backed listings)
bundle_dir = _write_bundle(tmp_path / "bundle", solution=bad_solution)
runner = CliRunner()
result = runner.invoke(cli_mod.main, ["publish", str(bundle_dir)])
assert result.exit_code == cli_mod.EXIT_VALIDATION, result.output
assert "evidence" in result.output
def test_publish_happy_path_opens_pr(tmp_path, config_env, monkeypatch):
calls = {"branch": 0, "files": [], "release": 0, "asset": 0, "pr": 0}
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/branches") and request.method == "POST":
calls["branch"] += 1
return httpx.Response(201, json={"name": f"listing/{LISTING_ID}"})
if "/contents/" in path and request.method == "POST":
calls["files"].append(path)
return httpx.Response(201, json={"content": {"path": path}})
if path.endswith("/releases") and request.method == "POST":
calls["release"] += 1
return httpx.Response(201, json={"id": 42, "tag_name": INSTALL_REF})
if path.endswith("/releases/42/assets") and request.method == "POST":
calls["asset"] += 1
return httpx.Response(201, json={"name": f"{INSTALL_REF}.tar.gz"})
if path.endswith("/pulls") and request.method == "POST":
calls["pr"] += 1
return httpx.Response(
201,
json={"html_url": f"https://registry.test/m2/market-registry/pulls/7"},
)
raise AssertionError(f"unexpected request: {request.method} {request.url}")
orig_init = httpx.Client.__init__
def patched_init(self, *args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
orig_init(self, *args, **kwargs)
monkeypatch.setattr(httpx.Client, "__init__", patched_init)
bundle_dir = _write_bundle(tmp_path / "bundle")
runner = CliRunner()
result = runner.invoke(cli_mod.main, ["publish", str(bundle_dir)])
assert result.exit_code == 0, result.output
assert "https://registry.test/m2/market-registry/pulls/7" in result.output
assert calls["branch"] == 1
assert len(calls["files"]) == 2
assert calls["release"] == 1
assert calls["asset"] == 1
assert calls["pr"] == 1

View file

@ -1,126 +0,0 @@
# m2-market CLI contract v1
The CLI is the only marketplace surface that calls m2-ledger directly. Store, Scout, and Hermes
consume the CLI JSON contract.
Config: `~/.m2-market/config.toml` mode `0600`.
Required keys: `operator_id`, `tenant`, `ledger_url`, `ledger_api_key`, `memory_api_url`,
`memory_api_key`, `forgejo_url`, `forgejo_token`, `apply_adapter`.
State: `~/.m2-market/state.json`. Install entries MUST store `grant_id` and `tx_group_id`.
## Verbs
- `m2-market search <query> [--type solution|capability|resource|service] [--limit N] [--json]`
- `m2-market show <listing_id> [--json]`
- `m2-market install <listing_id> [--yes] [--json]`
- `m2-market uninstall <listing_id> [--yes] [--json]`
- `m2-market upgrade <listing_id> [--to <semver>] [--yes] [--json]`
- `m2-market wallet [--operator <operator_id>] [--json]`
- `m2-market publish <bundle-dir> [--tenant-visibility <tenant-or-*>]`
- `m2-market package init|import-fedlearn|validate|build|diff-permissions|evidence-summary|verify-release`
- `m2-market reindex`
- `m2-market propose "<intent>" --tenant <tenant> --operator <operator_id> --json`
- `m2-market evidence capture|submit|show`
- `m2-market telemetry submit <event-jsonl>`
## Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success, including idempotent no-op install. |
| 1 | Usage error, unsupported operation, or direct install refused for `price.model=job`. |
| 2 | Listing, release, license, or local install state not found. |
| 3 | Insufficient funds or ledger payment declined before apply. |
| 4 | Apply, verify, uninstall, or upgrade failed after settlement; CLI attempted refund when applicable. |
| 5 | Schema validation, tenant firewall, secret scan, permissions, or publish gate rejection. |
| 6 | External dependency unavailable or deferred adapter path not landed. |
## `show --json`
The detail payload is frozen for Store and Scout:
```json
{
"schema_version": "m2.market.show.v1",
"listing": {
"schema_version": "m2.listing.v1",
"listing_id": "lst_mm-pdf-report",
"solution_id": "sol_mm-pdf-report",
"solution_version": "1.0.0",
"inventory_type": "solution",
"name": "MM PDF Report",
"summary": "Generate branded PDF reports.",
"category": "reporting",
"price": {"model": "fixed", "amount": 25, "currency": "m2cr"},
"seller": {"operator_id": "op_m2core", "display_name": "M2 Core"},
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"tenant_visibility": ["*"],
"status": "published",
"install_ref": "lst_mm-pdf-report-v1.0.0"
},
"solution": {
"schema_version": "m2.solution.v1",
"solution_id": "sol_mm-pdf-report",
"name": "MM PDF Report",
"summary": "Generate branded PDF reports.",
"intent": "Turn approved analysis into a styled client-ready PDF report.",
"deployment": {
"recipe_ref": "recipe.yaml",
"entrypoint": "mm-pdf generate <input.md>",
"verify_command": "mm-pdf --version"
},
"applicability": {
"image_classes": ["primus", "agent-latest"],
"roles": ["operator"],
"tool_requirements": ["bash"]
},
"tenant_scope": "m2-core",
"evidence": [{"source": "forgejo:m2/m2-market/listings/lst_mm-pdf-report/evidence/provenance.jsonl"}],
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"price": {"model": "fixed", "amount": 25, "currency": "m2cr"},
"seller": {"operator_id": "op_m2core", "display_name": "M2 Core"},
"license": {
"license_id": "lic_m2_market_standard_v1",
"terms": "m2-market-standard-v1",
"scope": "operator",
"major_version_coverage": true,
"allows_internal_modification": true,
"allows_redistribution": false,
"support": "best_effort"
}
},
"wallet": {"operator_id": "op_chris", "balance": 100, "currency": "m2cr", "as_of": "2026-07-02T00:00:00Z"},
"install_state": {
"status": "not_installed",
"installed_version": null,
"grant_id": null,
"tx_group_id": null
},
"permissions_diff": {
"schema_version": "m2.permissions_diff.v1",
"mode": "new_install",
"listing_id": "lst_mm-pdf-report",
"current_install_ref": null,
"target_install_ref": "lst_mm-pdf-report-v1.0.0",
"summary": {"added": 2, "removed": 0, "changed": 0, "unchanged": 0, "risk": "medium"},
"items": [
{
"permission_id": "fs-write-agent-home",
"action": "added",
"kind": "filesystem",
"access": "write",
"scope": "${AGENT_HOME}/.claude/skills/mm-pdf",
"target": "skill files",
"reason": "install skill files",
"risk": "low",
"requires_confirmation": false
}
]
}
}
```
`permissions_diff.mode` is one of `new_install`, `upgrade`, `reinstall`, `uninstall`, or
`no_change`. `items[].action` is one of `added`, `removed`, `changed`, or `unchanged`.

View file

@ -1,24 +0,0 @@
# M2 Store deep-link contract v1
Canonical listing detail URI:
```text
m2store://listing/<listing_id>?source=&proposal_id=&q=
```
Examples:
```text
m2store://listing/lst_competitor-scan?source=scout&proposal_id=prop_01j2abc
m2store://listing/lst_mm-pdf-report?source=hermes&q=pdf%20report
```
Rules:
- Path segment is `listing`, not `solution`.
- `<listing_id>` uses the `lst_` prefix from `contracts/ids.md`.
- Allowed query parameters are exactly `source`, `proposal_id`, and `q`.
- The URI is navigate-only. It never installs, purchases, grants licenses, or mutates state.
- Unknown listing IDs open Store search/detail error UI without leaking tenant-forbidden records.
- Store may keep an internal route such as `/solution/<listing_id>`, but the external protocol
stays `m2store://listing/<listing_id>`.

View file

@ -1,22 +0,0 @@
# Marketplace ID prefixes v1
Canonical prefixes:
| Prefix | Object | Example |
| --- | --- | --- |
| `op_` | Operator ID | `op_m2core` |
| `prop_` | Proposal ID | `prop_01j2abc` |
| `lst_` | Listing ID | `lst_mm-pdf-report` |
| `sub_` | Fedlearn submission ID | `sub_01j2candidate` |
| `clu_` | Fedlearn cluster ID | `clu_01j2cluster` |
| `core_` | Promoted core learning ID | `core_01j2learning` |
Rules:
- Operators use `op_<slug>` everywhere. Bare names such as `m2bd` or `sdjs-operator` are display
names, not marketplace IDs.
- Proposals use `prop_`, never `prp_`.
- Listings use `lst_` and control price, visibility, version, and license grants.
- Solution manifests keep `sol_` for `solution_id`; this is already frozen in schema and is not
a replacement for `lst_` in Store links or ledger refs.
- Ledger transaction groups, grants, accounts, and jobs keep S1-owned prefixes outside this file.

View file

@ -1,139 +0,0 @@
# m2-ledger API contract v1
S1 owns ledger semantics. Marketplace clients MUST use the `/v1/*` paths below; older
`/tx/install`, `/tx/refund`, `/license/*`, `/licenses/check`, and `/balance/*` paths are not
contractual for m2-market.
All mutation requests require `Idempotency-Key` or an `idempotency_key` body field. If both are
present, they must match. All amounts are integer `m2cr`.
## Identity
`GET /v1/accounts/me`
Returns the caller's resolved ledger identity:
```json
{
"account_id": "acct_op_chris",
"operator_id": "op_chris",
"tenant_id": "m2",
"scopes": ["ledger:read", "ledger:install"]
}
```
## Balances
`GET /v1/balances/{account_or_operator_id}`
Returns a balance derived from the append-only transaction log. Store and Scout do not call this
directly; they use `m2-market wallet`.
```json
{
"account_id": "acct_op_chris",
"operator_id": "op_chris",
"tenant_id": "m2",
"balance": 100,
"currency": "m2cr",
"as_of": "2026-07-02T00:00:00Z"
}
```
## Install settlement
`POST /v1/settlements/install`
Fixed-price install settlement. Job-priced listings are refused by the CLI before this call and
use the jobs API instead.
```json
{
"buyer_operator_id": "op_chris",
"seller_operator_id": "op_m2core",
"tenant_id": "m2",
"listing_id": "lst_mm-pdf-report",
"solution_id": "sol_mm-pdf-report",
"solution_version": "1.0.0",
"major_version": 1,
"install_ref": "lst_mm-pdf-report-v1.0.0",
"price": {"model": "fixed", "amount": 25, "currency": "m2cr"},
"revenue_split": {"platform_pct": 10, "seller_pct": 90}
}
```
Success returns the transaction group and license grant. The CLI MUST persist `tx_group_id` in
`~/.m2-market/state.json` before apply so refund can be addressed by group id.
```json
{
"tx_group_id": "txg_01j2install",
"grant_id": "gr_01j2license",
"balance": 75,
"license": {
"operator_id": "op_chris",
"listing_id": "lst_mm-pdf-report",
"major_version": 1,
"status": "active"
}
}
```
`402 insufficient_funds` writes no transactions or grant. Repeated idempotency keys return the
original settlement.
## Refund on apply failure
`POST /v1/settlements/install/{tx_group_id}/refund`
Refunds by `tx_group_id`, not by listing ref. Refunds are compensating append-only rows and mark
the related license grant `refunded`.
```json
{
"reason": "apply_failed",
"apply_error": "verify command failed",
"listing_id": "lst_mm-pdf-report"
}
```
## Licenses
`GET /v1/licenses/{operator_id}/{listing_id}?major_version=1`
Returns the active/refunded/revoked grant for `(operator_id, listing_id, major_version)`, or `404`
for not found or forbidden.
## Starter grants
`POST /v1/grants/starter`
Admin/service scoped. Default amount is `100 m2cr`.
## Jobs
`POST /v1/jobs`
Creates a funded job/escrow for job-priced listings. Job settlements use ledger `tx_type=job`.
Additional job paths:
- `POST /v1/jobs/{job_id}/start`
- `POST /v1/jobs/{job_id}/milestones/{milestone_id}/submit`
- `POST /v1/jobs/{job_id}/milestones/{milestone_id}/accept`
- `POST /v1/jobs/{job_id}/cancel`
- `POST /v1/jobs/{job_id}/dispute`
## Route/resource settlement
`POST /v1/settlements/route`
Records credit-denominated route/resource rows tied to m2-gpt metering references. It does not
modify m2-gpt metering.
## Audit snapshots
`POST /v1/admin/snapshots`
Writes deterministic balance/hash-chain snapshots to Forgejo repo `m2/market-audit`, not to the
registry repo `m2/m2-market`.

View file

@ -1,62 +0,0 @@
# Marketplace telemetry contract v1
All shown/opened/installed/dismissed marketplace telemetry uses one envelope:
`m2.market.telemetry.v1`.
`m2.market.evidence.v1` is reserved for S6 job evidence records only. Do not write install,
proposal, Store, Scout, or CLI telemetry with the evidence schema.
## Envelope
```json
{
"schema_version": "m2.market.telemetry.v1",
"event_id": "evt_01j2telemetry",
"event_type": "market.install.succeeded",
"occurred_at": "2026-07-02T00:00:00Z",
"tenant_id": "m2",
"operator_id": "op_chris",
"agent_id": "chris-m2o",
"listing_id": "lst_mm-pdf-report",
"solution_id": "sol_mm-pdf-report",
"solution_version": "1.0.0",
"proposal_id": "prop_01j2abc",
"source": "cli",
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"payload": {}
}
```
Required fields: `schema_version`, `event_id`, `event_type`, `occurred_at`, `tenant_id`,
`source`, and at least one of `listing_id`, `solution_id`, or `proposal_id`.
Telemetry must contain summaries and IDs only. No raw client data, secrets, transcript bodies, or
private job artifacts.
## Event type registry
- `market.listing.shown`
- `market.listing.opened`
- `market.proposal.generated`
- `market.proposal.shown`
- `market.proposal.opened`
- `market.proposal.accepted`
- `market.proposal.dismissed`
- `market.install.started`
- `market.install.succeeded`
- `market.install.failed`
- `market.install.refunded`
- `market.uninstall.succeeded`
- `market.upgrade.succeeded`
- `market.invoke.succeeded`
- `market.invoke.failed`
Indexers may fold telemetry into `listing.stats.installs`, `listing.stats.proposals_shown`, and
`listing.stats.proposals_accepted`. Self-purchases and self-generated proposal loops are excluded
from quality statistics.
## Job evidence reservation
`m2.market.evidence.v1` records are S6 job evidence records and carry `evidence_id`, `job_id`,
`wanted`, `tools`, `contributors`, `metering`, `resources`, `outcome`, `reusable_verdict`,
`scrub_status`, and `content_hash`. They are not a general event envelope.

View file

@ -1,34 +0,0 @@
# Tenant namespace contract v1
Canonical tenant IDs come from the live m2-gpt `tenants` table. Marketplace contracts must not
invent alternate sentinels or display-name aliases.
Live table read on 2026-07-02:
| tenant_id | display_name | status |
| --- | --- | --- |
| `erlengrund-m2o` | erlengrund-m2o | active |
| `gunnar-m2o` | gunnar-m2o | active |
| `m2` | m2 Operator | active |
| `m2bd-m2o` | m2bd-m2o | active |
| `matrix` | matrix | active |
| `parlobyg-m2o` | parlobyg-m2o | active |
| `smoke7623` | Smoke smoke7623 | active |
| `e2e-verify-0617` | E2E Verify delete me | archived |
Marketplace-reserved scopes:
- `*` in `listing.tenant_visibility` means public to all tenants.
- `m2-core` is the shared commons scope for reusable, scrubbed marketplace evidence and shared
Solutions. It replaces `__fleet__` for marketplace contracts.
Rules:
- `tenant_visibility` is an allowlist of `*` or canonical active tenant IDs.
- Empty tenant filters fail closed.
- Readers must apply the S2 filter contract:
`filters.tenant_visibility_any = ["*", "<caller_tenant>"]`, with client-side backstop that
drops hits where `status != "published"` or caller tenant is not visible.
- Cross-tenant not-found and forbidden states are surfaced as `404` to avoid existence leaks.
- `m2-core` is allowed for `solution.tenant_scope` and evidence scope, but it is not a buyer
tenant and is not a replacement for `tenant_visibility: ["*"]`.

60
docs/runbook.md Normal file
View file

@ -0,0 +1,60 @@
# m2-market operational runbook
## Catalog rebuild (constitution II rebuildability)
The live memory-api is store/search only — no delete, no partition drop — so
`m2-market-indexer` reindex is an **idempotent fill** of the current partition, not a
rebuild. A true rebuild (needed after listing updates/delists or index corruption) is a
**fresh-partition switch**:
```bash
# 1. Fill a fresh partition from the registry (source of truth)
MEMORY_API_PARTITION=market:catalog-v2 \
MEMORY_API_URL=... MEMORY_API_KEY=... FORGEJO_URL=... FORGEJO_TOKEN=... \
LISTING_SCHEMA_PATH=$REPO/schemas/listing.schema.json \
python3 -m m2_market_indexer.reindex
# 2. Switch every reader (CLI/Store/Scout) by config — no code change
export M2_MARKET_CATALOG_PARTITION=market:catalog-v2 # or config management
# 3. The old partition is abandoned in place (memory-api cannot delete it);
# it costs storage only. Fold the suffix rotation back when memory-api
# grows delete support (fedlearn T13 follow-up).
```
Verification: `m2-market search <known listing>` returns the same results against the
new partition (quickstart.md Scenario E).
## memory-api endpoint discovery
The live memory-api container IP changes across Coolify redeploys (known split-brain,
two stacks). Resolve the current one:
```bash
MC=$(docker ps --format '{{.Names}}' | grep memory-api | head -1)
docker inspect $MC | jq -r '.[0].NetworkSettings.Networks | to_entries[0].value.IPAddress'
```
Public route: `https://memory.machinemachine.ai`. Writes need `X-API-Key` (the key lives
in the memory-api container env as MEMORY_API_KEY); reads are currently open.
## Ledger
- Public: `https://ledger.machinemachine.ai` (Cloudflare TLS → Traefik http router — the
domain is registered `http://` in Coolify on purpose; re-adding `https://` recreates
the redirect loop).
- Deploy: push to `m2/m2-market` master → Forgejo webhook (hook id 14) → Coolify deploy.
Webhook delivery can lag ~40 s. Manual: `GET /api/v1/deploy?uuid=<app>` on Coolify.
- Keys: `/home/m2/.m2-ledger-keys` (host, 0600). Service key reads/installs; admin key
grants/snapshots.
- Daily snapshot: host cron 03:10 → `POST /snapshot` → commits
`m2/market-registry:audit/YYYY-MM-DD.json`. Balance disputes: recompute from the tx
log (`GET /tx`), which is append-only truth.
- Refund semantics: compensating refunds may overdraw a seller (debt position, settled
at manual payout reconciliation) — data-model.md §Transaction.
## Publish/status flow (known gap, analyze finding I2)
`m2-market publish` opens the PR with `status: "draft"`; nothing flips it on merge yet.
Until automated: after merging a listing PR, commit `status: "published"` on main (the
indexer only indexes published listings).

68
docs/scout.md Normal file
View file

@ -0,0 +1,68 @@
# Solution Scout v0
Solution Scout is a Hermes plugin that watches safe turn summaries, searches the M2
Marketplace catalog, and shows a non-blocking proposal when a published Solution appears to
cover the current task.
## Install
```bash
./scout/install.sh
```
The installer is an idempotent file drop to:
- `~/.hermes/plugins/m2-solution-scout/plugin.yaml`
- `~/.hermes/plugins/m2-solution-scout/__init__.py`
- `~/.config/m2-market/pull-policy.toml` only when that file does not already exist
It does not enable Scout. To opt in, edit `~/.config/m2-market/pull-policy.toml` and set:
```toml
[scout]
enabled = true
tenant_id = "machine-machine"
desktop_id = "chris-m2o"
operator_id = "op_chris"
```
## Guardrails
Scout fails closed. Missing config, `enabled=false`, or a missing `tenant_id` makes every hook
a no-op. `raw_keystrokes=true` or `raw_client_data=true` refuses session start. The plugin
summarizes and redacts on-box before memory-api egress, searches only `agent_id =
"market:catalog"`, filters hits by `tenant_visibility` containing the desktop tenant or `"*"`,
rate-limits proposals, and never calls `m2-market install`.
## Event Flow
On `on_session_start`, the plugin loads `pull-policy.toml`. On `post_llm_call`, it gates on
policy, rate caps, and cooldowns, then starts a background thread so slow catalog queries do
not block the operator turn. The worker builds an `m2.scout.intent_summary.v1`, posts
`/memory/search` with `agent_id=market:catalog`, applies client-side tenant filtering and
threshold checks, builds an `m2.scout.proposal.v1`, shows `notify-send` with a Store deep
link, and falls back to the Herdr socket toast when desktop notification delivery is
unavailable.
The deep link format is:
```text
m2store://listing/{listing_id}?proposal_id={proposal_id}&source=scout
```
## Telemetry Paths
Local state lives at:
```text
~/.local/share/m2-market/scout/state.json
```
Proposal telemetry is append-only JSONL:
```text
~/.local/share/m2-market/scout/outbox/YYYY-MM-DD.jsonl
```
The v0 plugin records `proposal_shown` locally for later batching to `market:evidence`.
Accept, dismiss, debit, grant, and install remain owned by M2 Store and `m2-market`.

View file

@ -11,6 +11,7 @@ from __future__ import annotations
import base64
import json
import sys
import httpx
@ -104,7 +105,17 @@ class MemoryClient:
"""Writer for the `market:catalog` partition (contracts/catalog-index.md).
ONLY m2-market-indexer writes here (reindex + watch); everything else is a
reader via CatalogClient's `/search`.
reader via CatalogClient.
Adapted 2026-07-02 to the REAL memory-api surface (agent.memory.system):
the API is store/search only no delete, no partition drop, no
upsert-by-id. So:
- "upsert" = search-first for metadata.listing_id, store only if absent
(idempotent fill; a listing UPDATE requires memory-api delete support
recorded limitation, fedlearn T13 follow-up).
- drop_partition / delete_document are WARN no-ops. Reindex therefore
cannot yet prove full rebuildability (constitution II) the registry
remains truth and the gap is tracked in docs/runbook.md.
"""
def __init__(
@ -113,14 +124,12 @@ class MemoryClient:
api_key: str,
partition: str,
*,
partition_path: str,
document_path: str,
timeout: float = 15.0,
partition_path: str = "", # retained for config compat; unused
document_path: str = "", # retained for config compat; unused
timeout: float = 30.0,
transport: httpx.BaseTransport | None = None,
) -> None:
self.partition = partition
self.partition_path = partition_path
self.document_path = document_path
self._client = httpx.Client(
base_url=base_url.rstrip("/"),
headers={"X-API-Key": api_key},
@ -137,29 +146,48 @@ class MemoryClient:
def __exit__(self, *exc_info: object) -> None:
self.close()
def _partition_url(self) -> str:
return self.partition_path.format(partition=self.partition)
def _document_url(self, doc_id: str) -> str:
return self.document_path.format(partition=self.partition, doc_id=doc_id)
def _exists(self, doc_id: str) -> bool:
resp = self._client.post(
"/memory/search",
json={"query": doc_id, "agent_id": self.partition, "limit": 10},
)
resp.raise_for_status()
return any(
(r.get("metadata") or {}).get("listing_id") == doc_id
for r in resp.json().get("results", [])
)
def drop_partition(self) -> None:
"""Delete the partition wholesale; a later upsert recreates it."""
resp = self._client.delete(self._partition_url())
if resp.status_code not in (200, 204, 404):
resp.raise_for_status()
"""No-op: memory-api has no partition delete (store/search only)."""
print(
f"WARN: memory-api cannot drop partition {self.partition}; "
"reindex is an idempotent fill, not a rebuild",
file=sys.stderr,
)
def upsert_document(self, doc_id: str, text: str, payload: dict) -> None:
resp = self._client.put(
self._document_url(doc_id),
json={"text": text, "payload": payload},
if self._exists(doc_id):
return
resp = self._client.post(
"/memory/store",
json={
"content": text,
"agent_id": self.partition,
"memory_type": "semantic",
"importance": 0.8,
"source": "m2-market-indexer",
"metadata": {"listing_id": doc_id, "listing": payload},
},
)
resp.raise_for_status()
def delete_document(self, doc_id: str) -> None:
resp = self._client.delete(self._document_url(doc_id))
if resp.status_code not in (200, 204, 404):
resp.raise_for_status()
"""No-op: memory-api has no delete; delisted listings are filtered at
read time by CatalogClient (status/visibility in the payload)."""
print(
f"WARN: memory-api cannot delete {doc_id}; relying on read-time filtering",
file=sys.stderr,
)
def build_embedded_text(listing: dict, solution: dict | None) -> str:

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import base64
import json
import os
import sys
from pathlib import Path
@ -25,6 +26,10 @@ from m2_market_indexer.client import MemoryClient, RegistryClient # noqa: E402
from m2_market_indexer.reindex import run # noqa: E402
SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas" / "listing.schema.json"
# Pin the schema for config.listing_schema_path(): when the package under test is the
# venv-installed copy (uv workspace), its parents[3]-based resolution lands outside the
# repo and loads the wrong file. The test's fixture contract is THIS repo's schema.
os.environ["LISTING_SCHEMA_PATH"] = str(SCHEMA_PATH)
LISTING_PUBLISHED = {
"schema_version": "m2.listing.v1",
@ -87,7 +92,12 @@ def _make_handlers():
def memory_handler(request: httpx.Request) -> httpx.Response:
calls.append(("memory", f"{request.method} {request.url.path}"))
return httpx.Response(200, json={"ok": True})
if request.url.path == "/memory/search":
# exists-check: nothing indexed yet
return httpx.Response(200, json={"results": [], "routing": {}})
if request.url.path == "/memory/store":
return httpx.Response(200, json={"id": "mem-1", "status": "stored"})
return httpx.Response(404, json={"message": "not found"})
return calls, registry_handler, memory_handler
@ -123,9 +133,11 @@ def _exercise_reindex_flow():
}, counts
memory_calls = [c for kind, c in calls if kind == "memory"]
# Real memory-api surface: drop is a no-op (store/search only); upsert is
# exists-check (search) then store — for the one published listing only.
assert memory_calls == [
"DELETE /partitions/market:catalog",
"PUT /partitions/market:catalog/documents/lst_pdf-export",
"POST /memory/search",
"POST /memory/store",
], memory_calls
registry_calls = [c for kind, c in calls if kind == "registry"]

View file

@ -143,6 +143,11 @@ def snapshot(conn=Depends(get_conn), _key: str = Depends(require_admin)):
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
path = f"audit/{date}.json"
content = json.dumps(balances, sort_keys=True, indent=2)
# TODO(T034): commit `content` to `path` in m2/market-registry via Forgejo API
# (REGISTRY_REPO_URL + FORGEJO_TOKEN); this endpoint only produces the JSON.
return {"path": path, "content": content}
from m2_ledger.snapshot import SnapshotError, commit_snapshot
try:
result = commit_snapshot(path, content)
except SnapshotError as exc:
# Snapshot content is still returned so an operator can commit by hand.
return {"path": path, "content": content, "committed": False, "error": str(exc)}
return {"path": path, "content": content, "committed": True, "commit": result["commit"]}

View file

@ -133,6 +133,9 @@ def record_install(
tx_ids.append(_insert_tx(conn, ts, buyer, "platform", cut, "install", ref))
paying_tx_id = net_tx_id if net_tx_id is not None else tx_ids[0]
# ref is "listing_id:machine:uuid" (contracts/ledger-api.md) — the grant is
# keyed by the bare listing_id so GET /license/{op}/{listing} resolves.
listing_id = ref.split(":", 1)[0]
grant_id = f"gr_{uuid.uuid4().hex}"
conn.execute(
"""
@ -140,7 +143,7 @@ def record_install(
solution_version_major, tx_id, ts)
VALUES (?, ?, ?, ?, ?, ?)
""",
(grant_id, buyer, ref, 1, paying_tx_id, ts),
(grant_id, buyer, listing_id, 1, paying_tx_id, ts),
)
grant_row = conn.execute(

View file

@ -0,0 +1,42 @@
"""Daily balance snapshot → committed to the registry's audit/ (constitution V, T034).
The commit uses the Forgejo contents API: create the file, or update it (same-day
re-runs) with the current blob sha. Config via env: FORGEJO_URL, FORGEJO_TOKEN,
REGISTRY_REPO (default m2/market-registry), FORGEJO_USER (default m2).
"""
from __future__ import annotations
import base64
import os
import httpx
class SnapshotError(RuntimeError):
pass
def commit_snapshot(path: str, content: str) -> dict:
forgejo_url = os.environ.get("FORGEJO_URL", "https://git.machinemachine.ai").rstrip("/")
token = os.environ.get("FORGEJO_TOKEN", "")
repo = os.environ.get("REGISTRY_REPO", "m2/market-registry")
user = os.environ.get("FORGEJO_USER", "m2")
if not token:
raise SnapshotError("FORGEJO_TOKEN not set — snapshot produced but not committed")
api = f"{forgejo_url}/api/v1/repos/{repo}/contents/{path}"
b64 = base64.b64encode(content.encode()).decode()
message = f"audit: ledger balance snapshot {path.rsplit('/', 1)[-1]}"
with httpx.Client(auth=(user, token), timeout=15.0) as client:
existing = client.get(api)
if existing.status_code == 200:
sha = existing.json().get("sha")
resp = client.put(api, json={"content": b64, "message": message, "sha": sha})
else:
resp = client.post(api, json={"content": b64, "message": message})
if resp.status_code not in (200, 201):
raise SnapshotError(f"registry commit failed: {resp.status_code} {resp.text[:200]}")
data = resp.json()
return {"path": path, "commit": (data.get("commit") or {}).get("sha")}

View file

@ -47,6 +47,7 @@ class LedgerMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.any_refund = False
self.conn = models.init_db(":memory:")
self.installed_refs = []
self._ref_counter = 0
@ -94,6 +95,7 @@ class LedgerMachine(RuleBasedStateMachine):
pass
else:
self.installed_refs.remove(ref)
self.any_refund = True
@invariant()
def balances_match_independent_fold(self):
@ -102,6 +104,12 @@ class LedgerMachine(RuleBasedStateMachine):
@invariant()
def no_non_mint_balance_goes_negative(self):
# Refund reversals are exempt: a seller who spent their earnings before
# the buyer's install was refunded legitimately goes negative (debt,
# settled at manual payout reconciliation — data-model.md §Transaction).
# Every OTHER path must keep non-mint balances >= 0.
if self.any_refund:
return
for account in _known_accounts(self.conn):
if account == "mint":
continue

View file

@ -1,47 +0,0 @@
# M2 Market Standard License v1
This license applies to marketplace Solutions that reference
`license_id=lic_m2_market_standard_v1` and `terms=m2-market-standard-v1`.
## Grant
After successful ledger settlement, M2 grants the purchasing operator a non-exclusive internal
commercial license to install and use the purchased Solution for the covered major version.
License grants are keyed by `(operator_id, listing_id, major_version)`.
## Scope
The licensed operator may use the Solution inside owned or client workspaces where the operator is
authorized to work, subject to the listing's tenant visibility, permissions, and applicable tenant
data policy.
## Major-version coverage
The grant covers all minor and patch releases in the purchased major version. A new major version
requires a new grant unless the listing explicitly states otherwise.
## Internal modification
Internal modification is allowed for installation, configuration, debugging, and workflow
adaptation. Modified copies remain internal and do not change the seller's support obligations.
## No redistribution
Redistribution, resale, public mirroring, or repackaging as another marketplace listing is not
allowed without explicit written permission from the seller and platform operator.
## Support
Support is best effort. Listings may include verification commands, rollback notes, and evidence,
but no uptime, fitness, or outcome warranty is implied.
## Delisting and refunds
Delisting does not revoke existing active grants. Refunds for failed apply flows are handled by
append-only ledger refund rows and may mark the related grant as refunded.
## Secrets and tenant data
The license does not permit embedding secrets, raw client data, or tenant-private artifacts in
published bundles, telemetry, or shared evidence. Tenant-derived material must pass the required
owner-initiation and double-scrub controls before shared commercial use.

View file

@ -5,7 +5,7 @@ description = "M2 Marketplace monorepo — schemas, ledger, CLI, indexer (spec 0
requires-python = ">=3.12"
[tool.uv.workspace]
members = ["ledger", "cli", "indexer"]
members = ["ledger", "cli", "indexer", "web/backend"]
[tool.ruff]
line-length = 100

View file

@ -1,9 +1,115 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.machinemachine.ai/m2/m2-market/schemas/listing.schema.json",
"title": "m2.listing.v1",
"description": "Catalog-facing projection of a marketplace listing. Forgejo m2/m2-market is truth; market:catalog is rebuildable.",
"$id": "https://git.machinemachine.ai/m2/market-registry/schemas/listing.schema.json",
"title": "Listing",
"description": "Catalog-facing record for a marketplace listing. Truth lives in m2/market-registry; derived copy indexed into market:catalog.",
"type": "object",
"properties": {
"schema_version": {
"const": "m2.listing.v1"
},
"listing_id": {
"type": "string",
"pattern": "^lst_[a-z0-9-]+$"
},
"solution_id": {
"type": "string",
"pattern": "^sol_[a-z0-9-]+$"
},
"solution_version": {
"type": "string"
},
"inventory_type": {
"type": "string",
"enum": ["solution", "capability", "resource", "service"]
},
"name": {
"type": "string"
},
"summary": {
"type": "string"
},
"category": {
"type": "string"
},
"keywords": {
"type": "array",
"items": {
"type": "string"
}
},
"icon": {
"type": "string"
},
"price": {
"type": "object",
"description": "Denormalized from solution at listing time.",
"properties": {
"amount": {
"type": "integer",
"minimum": 1
},
"currency": {
"const": "m2cr"
},
"model": {
"type": "string"
}
},
"required": ["amount", "currency", "model"],
"additionalProperties": false
},
"seller": {
"type": "string",
"description": "operator_id"
},
"evidence_summary": {
"type": "string"
},
"tenant_visibility": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"stats": {
"type": "object",
"description": "Conversion signals (FR-012), updated by telemetry, start zeroed.",
"properties": {
"installs": {
"type": "integer",
"minimum": 0,
"default": 0
},
"rating": {
"type": "number",
"minimum": 0,
"maximum": 5
},
"proposals_shown": {
"type": "integer",
"minimum": 0,
"default": 0
},
"proposals_accepted": {
"type": "integer",
"minimum": 0,
"default": 0
}
},
"required": ["installs", "proposals_shown", "proposals_accepted"],
"additionalProperties": false
},
"status": {
"type": "string",
"enum": ["draft", "in_review", "published", "delisted"]
},
"install_ref": {
"type": "string",
"pattern": "^lst_[a-z0-9-]+-v\\d+\\.\\d+\\.\\d+$"
}
},
"required": [
"schema_version",
"listing_id",
@ -15,133 +121,9 @@
"category",
"price",
"seller",
"content_hash",
"tenant_visibility",
"status",
"install_ref"
],
"properties": {
"schema_version": { "const": "m2.listing.v1" },
"listing_id": {
"type": "string",
"pattern": "^lst_[a-z0-9][a-z0-9-]*$"
},
"solution_id": {
"type": "string",
"pattern": "^sol_[a-z0-9][a-z0-9-]*$"
},
"solution_version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+([+-][0-9A-Za-z.-]+)?$"
},
"inventory_type": {
"type": "string",
"enum": ["solution", "capability", "resource", "service"]
},
"name": { "type": "string", "minLength": 1 },
"summary": { "type": "string", "minLength": 1 },
"category": { "type": "string", "minLength": 1 },
"keywords": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"default": []
},
"icon": { "type": "string" },
"price": { "$ref": "#/$defs/price" },
"seller": { "$ref": "#/$defs/seller" },
"evidence_summary": { "type": "string" },
"content_hash": { "$ref": "#/$defs/sha256" },
"tenant_visibility": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
},
"description": "Allowed tenants. [\"*\"] means public. Other values must be canonical tenant ids from contracts/tenants.md."
},
"stats": {
"type": "object",
"required": ["installs", "proposals_shown", "proposals_accepted"],
"properties": {
"installs": { "type": "integer", "minimum": 0, "default": 0 },
"rating": { "type": "number", "minimum": 0, "maximum": 5 },
"proposals_shown": { "type": "integer", "minimum": 0, "default": 0 },
"proposals_accepted": { "type": "integer", "minimum": 0, "default": 0 }
},
"additionalProperties": false
},
"status": {
"type": "string",
"enum": ["draft", "in_review", "published", "delisted"]
},
"install_ref": {
"type": "string",
"pattern": "^lst_[a-z0-9][a-z0-9-]*-v\\d+\\.\\d+\\.\\d+([+-][0-9A-Za-z.-]+)?$"
}
},
"additionalProperties": false,
"$defs": {
"sha256": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"price": {
"oneOf": [
{
"type": "object",
"required": ["model", "amount", "currency"],
"properties": {
"model": { "const": "fixed" },
"amount": { "type": "integer", "minimum": 1 },
"currency": { "const": "m2cr" }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["model", "currency", "quote", "settlement"],
"properties": {
"model": { "const": "job" },
"currency": { "const": "m2cr" },
"quote": {
"type": "object",
"required": ["min_amount", "max_amount", "expires_after_hours", "requires_acceptance"],
"properties": {
"min_amount": { "type": "integer", "minimum": 1 },
"max_amount": { "type": "integer", "minimum": 1 },
"expires_after_hours": { "type": "integer", "minimum": 1 },
"requires_acceptance": { "type": "boolean" }
},
"additionalProperties": false
},
"settlement": {
"type": "object",
"required": ["ledger_reason", "escrow_required"],
"properties": {
"ledger_reason": { "const": "job" },
"escrow_required": { "type": "boolean" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"seller": {
"type": "object",
"required": ["operator_id", "display_name"],
"properties": {
"operator_id": {
"type": "string",
"pattern": "^op_[a-z0-9][a-z0-9-]*$"
},
"display_name": { "type": "string", "minLength": 1 },
"contact_ref": { "type": "string", "minLength": 1 },
"tenant_id": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
}
}
"additionalProperties": false
}

View file

@ -1,9 +1,200 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.machinemachine.ai/m2/m2-market/schemas/solution.schema.json",
"title": "m2.solution.v1",
"description": "Commercial Solution manifest. v1 is a marketplace superset of the fedlearn core fields: applicability, tenant_scope, evidence, content_hash, and scrub_status for tenant-derived work.",
"$id": "https://git.machinemachine.ai/m2/market-registry/schemas/solution.schema.json",
"title": "Solution",
"description": "The installable bundle manifest for the m2 marketplace. Frozen v1 contract (specs/001-market-first-wedge/data-model.md, research.md R3).",
"type": "object",
"properties": {
"schema_version": {
"const": "m2.solution.v1"
},
"solution_id": {
"type": "string",
"pattern": "^sol_[a-z0-9-]+$"
},
"name": {
"type": "string",
"minLength": 1
},
"summary": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"intent": {
"type": "string",
"minLength": 1
},
"behavior": {
"type": "object"
},
"tools": {
"type": "array",
"items": {
"type": "object"
}
},
"runtime": {
"type": "object"
},
"memory_schema": {
"type": "object"
},
"permissions": {
"type": "array",
"items": {
"type": "object"
}
},
"deployment": {
"type": "object",
"description": "recipe.yaml ref + entrypoint + verify command",
"properties": {
"recipe_ref": {
"type": "string",
"minLength": 1
},
"entrypoint": {
"type": "string",
"minLength": 1
},
"verify_command": {
"type": "string",
"minLength": 1
}
},
"required": ["recipe_ref", "entrypoint", "verify_command"],
"additionalProperties": false
},
"applicability": {
"type": "object",
"description": "fedlearn-compatible applicability constraints",
"properties": {
"image_classes": {
"type": "array",
"items": {
"type": "string"
}
},
"roles": {
"type": "array",
"items": {
"type": "string"
}
},
"tool_requirements": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"tenant_scope": {
"type": "string",
"minLength": 1,
"description": "'m2-core' for shared/free, or a tenant id for tenant-scoped work"
},
"owner_initiated": {
"type": "boolean"
},
"scrub_status": {
"type": "object",
"properties": {
"double_scrubbed": {
"type": "boolean"
}
},
"additionalProperties": true
},
"evidence": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"source": {
"type": "string",
"minLength": 1
},
"excerpt": {
"type": "string"
},
"machine": {
"type": "string"
},
"session": {
"type": "string"
},
"tokens": {
"type": "integer",
"minimum": 0
},
"wall_time": {
"type": "number",
"minimum": 0
}
},
"required": ["source"],
"additionalProperties": false
}
},
"content_hash": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"price": {
"type": "object",
"properties": {
"amount": {
"type": "integer",
"minimum": 1
},
"currency": {
"const": "m2cr"
},
"model": {
"type": "string",
"enum": ["fixed"]
}
},
"required": ["amount", "currency", "model"],
"additionalProperties": false
},
"seller": {
"type": "string",
"minLength": 1,
"description": "operator_id"
},
"license": {
"type": "object",
"properties": {
"terms": {
"type": "string",
"minLength": 1
},
"major_version_coverage": {
"const": true
}
},
"required": ["terms", "major_version_coverage"],
"additionalProperties": false
},
"revenue_split": {
"type": "object",
"properties": {
"platform_pct": {
"type": "number",
"minimum": 0,
"maximum": 100
}
},
"additionalProperties": false
}
},
"required": [
"schema_version",
"solution_id",
@ -19,238 +210,30 @@
"seller",
"license"
],
"properties": {
"schema_version": { "const": "m2.solution.v1" },
"solution_id": {
"type": "string",
"pattern": "^sol_[a-z0-9][a-z0-9-]*$"
},
"name": { "type": "string", "minLength": 1 },
"summary": { "type": "string", "minLength": 1 },
"description": { "type": "string" },
"intent": { "type": "string", "minLength": 1 },
"behavior": { "type": "object", "additionalProperties": true },
"tools": {
"type": "array",
"items": { "type": "object", "additionalProperties": true },
"default": []
},
"runtime": { "type": "object", "additionalProperties": true },
"memory_schema": { "type": "object", "additionalProperties": true },
"permissions": {
"type": "array",
"items": { "$ref": "#/$defs/permission" },
"default": []
},
"deployment": {
"type": "object",
"required": ["recipe_ref", "entrypoint", "verify_command"],
"properties": {
"recipe_ref": { "type": "string", "minLength": 1 },
"entrypoint": { "type": "string", "minLength": 1 },
"verify_command": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"applicability": { "$ref": "#/$defs/applicability" },
"tenant_scope": {
"type": "string",
"minLength": 1,
"description": "Canonical tenant id from contracts/tenants.md. Use m2-core for shared commons/evidence."
},
"owner_initiated": { "type": "boolean" },
"scrub_status": {
"type": "object",
"properties": {
"double_scrubbed": { "type": "boolean" },
"reviewers": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": true
},
"evidence": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/evidence" }
},
"content_hash": { "$ref": "#/$defs/sha256" },
"price": { "$ref": "#/$defs/price" },
"seller": { "$ref": "#/$defs/seller" },
"license": { "$ref": "#/$defs/license" },
"revenue_split": { "$ref": "#/$defs/revenue_split" }
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "tenant_scope": { "const": "m2-core" } },
"required": ["tenant_scope"]
"if": {
"properties": {
"tenant_scope": {
"const": "m2-core"
}
},
"required": ["tenant_scope"]
},
"else": {
"properties": {
"owner_initiated": {
"const": true
},
"else": {
"scrub_status": {
"type": "object",
"properties": {
"owner_initiated": { "const": true },
"scrub_status": {
"type": "object",
"required": ["double_scrubbed"],
"properties": {
"double_scrubbed": { "const": true }
},
"additionalProperties": true
"double_scrubbed": {
"const": true
}
},
"required": ["owner_initiated", "scrub_status"]
"required": ["double_scrubbed"]
}
}
],
"$defs": {
"sha256": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"applicability": {
"type": "object",
"required": ["image_classes", "roles", "tool_requirements"],
"properties": {
"image_classes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"roles": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"tool_requirements": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false
},
"evidence": {
"type": "object",
"required": ["source"],
"properties": {
"source": { "type": "string", "minLength": 1 },
"excerpt": { "type": "string" },
"machine": { "type": "string" },
"session": { "type": "string" },
"tokens": { "type": "integer", "minimum": 0 },
"wall_time": { "type": "number", "minimum": 0 },
"content_hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": true
},
"price": {
"oneOf": [
{
"type": "object",
"required": ["model", "amount", "currency"],
"properties": {
"model": { "const": "fixed" },
"amount": { "type": "integer", "minimum": 1 },
"currency": { "const": "m2cr" }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["model", "currency", "quote", "settlement"],
"properties": {
"model": { "const": "job" },
"currency": { "const": "m2cr" },
"quote": {
"type": "object",
"required": ["min_amount", "max_amount", "expires_after_hours", "requires_acceptance"],
"properties": {
"min_amount": { "type": "integer", "minimum": 1 },
"max_amount": { "type": "integer", "minimum": 1 },
"expires_after_hours": { "type": "integer", "minimum": 1 },
"requires_acceptance": { "type": "boolean" }
},
"additionalProperties": false
},
"settlement": {
"type": "object",
"required": ["ledger_reason", "escrow_required"],
"properties": {
"ledger_reason": { "const": "job" },
"escrow_required": { "type": "boolean" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"seller": {
"type": "object",
"required": ["operator_id", "display_name"],
"properties": {
"operator_id": {
"type": "string",
"pattern": "^op_[a-z0-9][a-z0-9-]*$"
},
"display_name": { "type": "string", "minLength": 1 },
"contact_ref": { "type": "string", "minLength": 1 },
"tenant_id": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"license": {
"type": "object",
"required": ["license_id", "terms", "scope", "major_version_coverage", "allows_internal_modification", "allows_redistribution", "support"],
"properties": {
"license_id": { "const": "lic_m2_market_standard_v1" },
"terms": { "const": "m2-market-standard-v1" },
"scope": { "const": "operator" },
"major_version_coverage": { "const": true },
"allows_internal_modification": { "const": true },
"allows_redistribution": { "const": false },
"support": { "const": "best_effort" }
},
"additionalProperties": false
},
"revenue_split": {
"type": "object",
"required": ["platform_pct", "seller_pct"],
"properties": {
"platform_pct": { "type": "integer", "minimum": 0, "maximum": 100 },
"seller_pct": { "type": "integer", "minimum": 0, "maximum": 100 },
"contributors": {
"type": "array",
"items": {
"type": "object",
"required": ["operator_id", "pct"],
"properties": {
"operator_id": {
"type": "string",
"pattern": "^op_[a-z0-9][a-z0-9-]*$"
},
"pct": { "type": "integer", "minimum": 1, "maximum": 100 }
},
"additionalProperties": false
},
"default": []
}
},
"additionalProperties": false
},
"permission": {
"type": "object",
"required": ["permission_id", "kind", "access", "scope", "target"],
"properties": {
"permission_id": { "type": "string", "minLength": 1 },
"kind": { "type": "string", "minLength": 1 },
"access": { "type": "string", "minLength": 1 },
"scope": { "type": "string", "minLength": 1 },
"target": { "type": "string", "minLength": 1 },
"reason": { "type": "string" },
"secret_ref": { "type": "string" }
},
"additionalProperties": true
}
"required": ["owner_initiated", "scrub_status"]
}
}

View file

@ -12,11 +12,7 @@
"currency": "m2cr",
"model": "fixed"
},
"seller": {
"operator_id": "op_m2core",
"display_name": "M2 Core"
},
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"seller": "sdjs-operator",
"tenant_visibility": ["*"],
"status": "archived",
"install_ref": "lst_mm-pdf-report-v1.0.0"

View file

@ -12,11 +12,7 @@
"currency": "m2cr",
"model": "fixed"
},
"seller": {
"operator_id": "op_m2core",
"display_name": "M2 Core"
},
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"seller": "sdjs-operator",
"status": "published",
"install_ref": "lst_mm-pdf-report-v1.0.0"
}

View file

@ -26,17 +26,9 @@
"currency": "m2cr",
"model": "fixed"
},
"seller": {
"operator_id": "op_m2core",
"display_name": "M2 Core"
},
"seller": "sdjs-operator",
"license": {
"license_id": "lic_m2_market_standard_v1",
"terms": "m2-market-standard-v1",
"scope": "operator",
"major_version_coverage": true,
"allows_internal_modification": true,
"allows_redistribution": false,
"support": "best_effort"
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
"major_version_coverage": true
}
}

View file

@ -22,17 +22,9 @@
"currency": "m2cr",
"model": "fixed"
},
"seller": {
"operator_id": "op_m2core",
"display_name": "M2 Core"
},
"seller": "sdjs-operator",
"license": {
"license_id": "lic_m2_market_standard_v1",
"terms": "m2-market-standard-v1",
"scope": "operator",
"major_version_coverage": true,
"allows_internal_modification": true,
"allows_redistribution": false,
"support": "best_effort"
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
"major_version_coverage": true
}
}

View file

@ -26,17 +26,9 @@
"currency": "m2cr",
"model": "fixed"
},
"seller": {
"operator_id": "op_m2core",
"display_name": "M2 Core"
},
"seller": "sdjs-operator",
"license": {
"license_id": "lic_m2_market_standard_v1",
"terms": "m2-market-standard-v1",
"scope": "operator",
"major_version_coverage": true,
"allows_internal_modification": true,
"allows_redistribution": false,
"support": "best_effort"
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
"major_version_coverage": true
}
}

View file

@ -8,20 +8,14 @@
"summary": "Generate Machine.Machine branded PDF reports and slide decks from Markdown",
"category": "reporting",
"keywords": ["pdf", "report", "branded", "slides"],
"icon": "https://git.machinemachine.ai/m2/m2-market/assets/mm-pdf-report/icon.png",
"icon": "https://git.machinemachine.ai/m2/market-registry/assets/mm-pdf-report/icon.png",
"price": {
"amount": 500,
"currency": "m2cr",
"model": "fixed"
},
"seller": {
"operator_id": "op_m2core",
"display_name": "M2 Core",
"contact_ref": "forgejo:m2/m2-market",
"tenant_id": "m2"
},
"seller": "sdjs-operator",
"evidence_summary": "Generated branded PDF report for SDJS quarterly summary; operator confirmed styling matched brand deck.",
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"tenant_visibility": ["*"],
"stats": {
"installs": 0,

View file

@ -27,12 +27,8 @@
},
"permissions": [
{
"permission_id": "fs-write-output",
"kind": "filesystem",
"access": "write",
"scope": "agent",
"target": "/tmp/mm-pdf-output",
"reason": "write rendered report output"
"scope": "filesystem:write",
"path": "/tmp/mm-pdf-output"
}
],
"deployment": {
@ -62,23 +58,12 @@
"currency": "m2cr",
"model": "fixed"
},
"seller": {
"operator_id": "op_m2core",
"display_name": "M2 Core",
"contact_ref": "forgejo:m2/m2-market",
"tenant_id": "m2"
},
"seller": "sdjs-operator",
"license": {
"license_id": "lic_m2_market_standard_v1",
"terms": "m2-market-standard-v1",
"scope": "operator",
"major_version_coverage": true,
"allows_internal_modification": true,
"allows_redistribution": false,
"support": "best_effort"
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
"major_version_coverage": true
},
"revenue_split": {
"platform_pct": 10,
"seller_pct": 90
"platform_pct": 10
}
}

View file

@ -32,16 +32,6 @@ def errors(validator, instance):
return list(validator.iter_errors(instance))
def flatten_errors(errs):
flat = []
stack = list(errs)
while stack:
err = stack.pop()
flat.append(err)
stack.extend(err.context)
return flat
# --- valid fixtures ---------------------------------------------------------
def test_valid_solution_passes():
@ -86,11 +76,10 @@ def test_solution_tenant_violation_fails_on_else_branch():
def test_solution_bad_price_fails_on_price_amount_minimum():
errs = errors(solution_validator, fixture("solution_bad_price.json"))
assert errs, "expected validation errors"
flat = flatten_errors(errs)
assert any(
e.validator == "minimum" and list(e.path) in (["price", "amount"], ["amount"])
for e in flat
), [(e.validator, list(e.path), e.message) for e in flat]
e.validator == "minimum" and list(e.path) == ["price", "amount"]
for e in errs
), [(e.validator, list(e.path), e.message) for e in errs]
# --- invalid listing fixtures ------------------------------------------------

218
scout/SPIKE.md Normal file
View file

@ -0,0 +1,218 @@
# Scout Host Spike (T041)
Time-boxed, read-mostly investigation of this host (m2, the machine this repo lives on)
to pick where the Solution Scout (Story 5 / FR-011) should live. Evaluates the three
options named in research.md §R6: (A) standalone supervised watcher, (B) Hermes plugin,
(C) herdr plugin/hook — against (a) access to structured summaries without raw
keystrokes, (b) opt-in enforcement point, (c) on-box summarization cost, (d) notification
surface, (e) update/rollout story across the `primus` and `agent-latest` image classes.
Note: `specs/S4-solution-scout.md` §5 already drafted a recommendation (Option A) before
this spike ran. This report re-derives the decision from concrete file/path evidence
gathered on the live host, and **overrides that draft** — see §5 for why.
## 0. Fleet context confirmed on this host
- Two desktop image classes exist, and they are **not** interchangeable for rollout:
- `primus` — built from `/home/m2/m2o/desktop/Dockerfile`, this repo's own supervisord
config, canary machine `chris-m2o`. System/image changes here mean re-baking primus
(`/home/m2/m2o/desktop/README.md:74`, `:95-104`).
- `agent-latest``ghcr.io/machine-machine/m2-desktop:agent-latest`, described
repeatedly in `/home/m2/m2o/.planning/federated-learning/*` as "legacy/Coolify",
a **different image lineage** this repo does not build
(`m2o/.planning/federated-learning/proposals/A4-propagation-rollout.md:5,17`).
- The federated-learning propagation ADR is explicit: *"image/system changes are baked
into `m2-desktop:primus`... This avoids pretending that `primus` and legacy/Coolify
`agent-latest` machines can be updated the same way"* (A4:5). Constitution VI says the
same: "runtime-sync is the only universal install path across mixed desktop images."
- Concretely on this host, every desktop-level supervised program (`hermes-gateway`,
`herdr-integrations`, `ttyd`) is wired in by `Dockerfile` `COPY ... >> supervisord.conf`
steps at **build time** (`m2o/desktop/Dockerfile:73-126`) into
`/etc/supervisor/conf.d/supervisord.conf`. That file is not part of any volume that
runtime-sync touches — it only exists post-bake, inside the image.
This single fact is the crux of the spike: **adding a new supervised process is an
image-bake operation**, reachable on `primus` (we own the Dockerfile) but not on
`agent-latest` (we don't own that image's build) without a separate, per-machine,
non-idempotent `docker exec` patch to a running container's supervisord config — exactly
the kind of fleet-wide-blast / non-reversible operation constitution VI rules out.
## 1. Option A — Standalone supervised watcher
Evidence:
- Pattern to copy: `hermes-gateway.supervisor.conf` (`m2o/desktop/hermes/hermes-gateway.supervisor.conf`)
`[program:hermes-gateway]`, `autorestart=true`, `priority=45`, `sleep 12` boot stagger,
appended into the active `supervisord.conf` via `Dockerfile:79-82`.
- Adding `m2-solution-scout` the same way means a third `COPY ... >> supervisord.conf`
block in `m2o/desktop/Dockerfile`, i.e. a `primus` image cut (`VERSION=vN ./build.sh`,
README.md:99).
- **Rollout gap (the finding S4 missed):** there is no equivalent Dockerfile for
`agent-latest` in this repo — it's pulled pre-built from `ghcr.io`. Reaching it means
either waiting for whoever owns that image to add the program, or `docker exec`-patching
each running `agent-latest` container's `/etc/supervisor/conf.d/supervisord.conf` and
`supervisorctl update` by hand per machine. That is not idempotent/reversible/runtime-sync
in the sense constitution VI and the "Inherited Hard Constraints" section require.
Scoring: strongest privacy isolation (a dedicated process only reads what it's told to),
but the update/rollout story (e) is the weakest of the three on THIS fleet, not the
strongest as S4 assumed.
## 2. Option B — Hermes plugin
Evidence:
- Hermes already runs a real plugin on this host:
`/home/m2/.hermes/plugins/herdr-agent-state/{__init__.py,plugin.yaml}`, installed by
`herdr integration install hermes` (confirmed via `herdr integration` subcommand list;
this exact plugin ships with `# HERDR_INTEGRATION_ID=hermes` header). It registers
hooks via `ctx.register_hook(...)` for `on_session_start`, `pre_llm_call`,
`pre_tool_call`, `post_tool_call`, `pre_approval_request`, `post_approval_response`,
`post_llm_call`, `on_session_end`, `on_session_finalize` — reporting only lifecycle
state (`working|blocked|idle`) over the herdr Unix socket, never message content.
- Plugin hook docs (`/home/m2/.hermes/hermes-agent/website/docs/user-guide/features/hooks.md`)
show the two hooks a Scout would actually want:
- `post_llm_call(session_id, user_message, assistant_response, conversation_history,
model, platform, **kwargs)` — fires once per turn, **already gives structured,
per-turn intent content** (not raw keystrokes — it's the resolved message/response
text, the same thing Hermes itself just processed).
- `pre_llm_call(...) -> {"context": str}` — could be reused later to inject a
"here's a matching Solution" hint directly into the next turn instead of only toasting.
- Deployment is a pure file drop: `~/.hermes/plugins/m2-solution-scout/{__init__.py,plugin.yaml}`.
No supervisord, no image bake — this is exactly the "runtime fleet-sync agent into
persisted home volumes" path the federated-learning ADR (A4) calls out as the one
path that reaches BOTH image classes, **because Hermes itself is already supervised
on both** (hermes-gateway is the canonical per-desktop supervision pattern referenced
by this very task, and its plugin directory lives under `$HERMES_HOME` regardless of
which image booted it).
- Opt-in enforcement point: hooks are in-process Python: a `pull-policy.toml` read at
plugin `register()` time can make every hook a no-op when `scout.enabled=false`,
exactly like the CLAUDE.md-documented pattern for other opt-in config.
- On-box summarization cost: cheapest of the three — the plugin receives already-clean
turn text; no separate log-tailing/parsing/redaction pass over external files is needed
before summarizing, only redaction of the text it's handed directly (same
`redact_secrets`/`redact_client_identifiers` obligations from S4's `pull-policy.toml` draft).
- Notification surface: none built in — the plugin still needs to shell out to
`notify-send` (XFCE) or the herdr toast socket (see Option C) for the actual popup.
This is a small, one-file bridge either way.
Cons confirmed from the doc: plugin hooks run **in-process** with Hermes (same trust
boundary — a crashing hook is caught and logged per the docs, "never crashing the agent",
but a slow hook still adds latency to every turn). And it only sees Hermes sessions —
OpenClaw-only or bare-herdr (Claude Code/Codex panes with no Hermes chat active) sessions
are invisible to it.
## 3. Option C — herdr plugin/hook
Evidence:
- `herdr agent list` (run once, per task instructions) returns a live, structured JSON
lifecycle feed today: `{agent, agent_status: working|idle|done, cwd, pane_id,
workspace_id, ...}` for every pane across every workspace on the box — genuinely
"structured run/lifecycle summary," zero raw keystrokes, already running, no code to write.
- `~/.herdr/runs/*.md` (e.g. `2026-07-02-m2-market-first-wedge-gate.md`) are rich,
human/agent-authored narrative summaries — but they are a **manual convention** written
by an orchestrating session at the end of a herd run (see this repo's own recent runs),
not an automatic per-session artifact. Most single-agent sessions never produce one.
This makes Option C's "structured summary" source sparse and bursty rather than
continuous — a poor fit for "an operator is mid-session, starts building something... an
agent proposes the link right then" (CONCEPT.md §5).
- `~/.claude/hooks/herdr-agent-state.sh` (installed by `herdr integration install claude`)
shows the wire protocol: a Claude Code hook posts `pane.report_agent_session` over
`$HERDR_SOCKET_PATH` — again lifecycle-only, no content.
- `~/.config/herdr/config.toml` already has `[ui.toast] delivery = "herdr"` — confirms
herdr owns a native toast/notification surface today (this is also directly reusable
by Options A/B as the notification backend, independent of which host wins).
- `herdr integration` subcommands (`install/uninstall {pi,omp,claude,codex,copilot,
opencode,hermes,qodercli}`, `status`) show integrations are themselves file-drops under
`~/.claude/hooks/`, `~/.hermes/plugins/`, etc. — same runtime-sync-friendly deploy story
as Option B.
Scoring: best-in-class for (b) opt-in enforcement (a single socket message gate) and (d)
notification, but weakest for (a) — the only *continuous* signal herdr exposes natively is
coarse lifecycle state (working/idle/done + cwd), not intent. Getting real intent out of
herdr alone means depending on the sparse `runs/*.md` convention, which most sessions
don't produce.
## 4. Scored comparison
| Criterion | A: Standalone watcher | B: Hermes plugin | C: herdr plugin/hook |
|---|---|---|---|
| (a) structured intent w/o raw keystrokes | Depends entirely on tap sources it's given — none exist continuously today | **Best**`post_llm_call` gives per-turn content already, every turn | Weak — only continuous signal is lifecycle state; real intent needs sparse `runs/*.md` |
| (b) opt-in enforcement point | Own config file, own gate — clean but net-new | Clean — gate inside `register()` before any hook does work | **Best** — one socket message, herdr already gates its own integrations |
| (c) on-box summarization cost | Highest — must tail/parse/redact multiple external files first | **Lowest** — handed clean text directly | Low for lifecycle, but high if it also has to parse `runs/*.md` |
| (d) notification surface | Must shell out (herdr toast or XFCE) | Must shell out (herdr toast or XFCE) | **Native** — herdr toast is its own surface, `[ui.toast]` already configured |
| (e) update/rollout, both image classes | **Weakest** — new supervisor program = image bake; no equivalent Dockerfile for `agent-latest` | **Best** — pure file drop under `$HERMES_HOME`, works wherever Hermes already runs (both classes) | Good — file drop under `~/.claude/hooks`/`~/.hermes/plugins` via `herdr integration install`, but only reaches herdr-managed panes |
## 5. Recommendation
**DECISION: Hermes plugin (Option B)**
Rationale: FR-011 and Story 5's hardest constraints are (1) real per-turn intent
without raw keystrokes, and (5) a rollout story that doesn't require rebuilding either
image class. Concrete evidence on this host shows Option A fails (5) outright — the only
mechanism this repo has for registering a new supervised program is a `Dockerfile` COPY
into `supervisord.conf`, which has no `agent-latest` equivalent, making that option
image-bake-only on one of the two live classes. Option C is native for opt-in/notification
but its only continuous, low-cost signal is coarse lifecycle state — it would need Option
B's or Option A's tap to get real intent anyway. Option B satisfies (1) directly
(`post_llm_call` hands over already-resolved turn text, cheapest to summarize-on-box), and
(5) matches proven fact: this host already runtime-installs a real Hermes plugin
(`herdr-agent-state`) as a pure file drop, no image rebuild, and Hermes is the one
component already supervised identically across both `primus` and `agent-latest`
(that's the same "hermes-gateway pattern" this task was pointed at as reference).
Caveat worth carrying into T042: a Hermes-hosted Scout is blind to bare-herdr sessions
that never touch Hermes chat (a Claude Code/Codex pane with no Hermes turn). This does
**not** violate the "Hermes first, never Hermes-only" inherited constraint — that
constraint is about marketplace touchpoints in general (Store/CLI/catalog remain
Hermes-independent), and Story 5's own acceptance criteria only require discovery "via
Scout **or** Hermes/CLI." It is, however, a real coverage gap: pure-herdr desktops get no
push-side discovery in v0. Cheap mitigation for a later phase, not blocking: also register
`herdr integration install hermes`'s existing socket report as a secondary opt-in trigger
so the same plugin can request the herdr toast surface (`[ui.toast] delivery = "herdr"`)
instead of shelling to `notify-send`, without adding a second host.
## 6. v0 implementation sketch
Components:
- `~/.hermes/plugins/m2-solution-scout/`
- `plugin.yaml``name: m2-solution-scout`, `description: Solution Scout intent watch`.
- `__init__.py``register(ctx)` wires `on_session_start` (load `pull-policy.toml`,
no-op immediately if `scout.enabled=false` or `tenant_id` missing — fail closed, per
S4's policy rules) and `post_llm_call` (the intent tap).
- Config: reuse S4's existing `pull-policy.toml` schema verbatim
(`~/.config/m2-market/pull-policy.toml`, `[scout]` block, `raw_keystrokes`/
`raw_client_data` reserved-deny keys, `max_proposals_per_day`, cooldowns). No new
config format — the plugin is a new *host* for the same contract S4 already specified.
- State/telemetry: same paths as S4 §3 (`~/.local/share/m2-market/scout/state.json`,
`outbox/YYYY-MM-DD.jsonl`) — the plugin process just runs inside Hermes instead of as
its own daemon.
Event flow (per turn, inside `post_llm_call`):
1. Gate: `scout.enabled` true, tenant_id present, rate cap and per-session cap not yet
hit, cooldown not active for the last dismissed listing → else return immediately.
2. Take `user_message` + `assistant_response` (bounded to `summary.max_source_chars`),
redact secrets/client identifiers per `pull-policy.toml`, produce
`m2.scout.intent_summary.v1` (S4 schema, unchanged) — deterministic extractive summary
in v0, no extra LLM call needed since the plugin already has clean turn text.
3. `POST /memory/search` to memory-api, `agent_id=market:catalog`, tenant-filtered (S4 §3
catalog query, unchanged).
4. Score/coverage threshold check (S4's `m2.scout.match.v1`) → if it clears, build
`m2.scout.proposal.v1` and fire the toast (`notify-send` primary, herdr socket toast as
fallback/secondary surface) with the M2 Store deep link.
5. Write `proposal_shown` telemetry to local outbox (batched flush to `market:evidence`,
same as S4 §3/§4) — Scout never calls `m2-market install` itself; Store/CLI own
debit→grant→apply and emit the accept/dismiss events this plugin later folds back in.
6. All of steps 2-5 run off the hot path where possible (background thread, mirroring the
`boot-md` hook tutorial's pattern of not blocking the turn on non-essential work) so a
slow catalog query never adds latency to the operator's actual turn.
Guardrails carried over unchanged from FR-011 / S4: default `enabled=false`; opt-in is
per-desktop `pull-policy.toml`; `raw_keystrokes`/`raw_client_data` keys hard-refuse start
if true; rate cap default 5/day; propose-only — install always requires the human's click
through M2 Store.
DECISION: Hermes plugin

22
scout/install.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_SRC="${SRC_DIR}/plugin/m2-solution-scout"
PLUGIN_DST="${HOME}/.hermes/plugins/m2-solution-scout"
CONFIG_DIR="${HOME}/.config/m2-market"
CONFIG_PATH="${CONFIG_DIR}/pull-policy.toml"
mkdir -p "${PLUGIN_DST}" "${CONFIG_DIR}"
install -m 0644 "${PLUGIN_SRC}/plugin.yaml" "${PLUGIN_DST}/plugin.yaml"
install -m 0644 "${PLUGIN_SRC}/__init__.py" "${PLUGIN_DST}/__init__.py"
if [[ ! -f "${CONFIG_PATH}" ]]; then
install -m 0600 "${SRC_DIR}/pull-policy.example.toml" "${CONFIG_PATH}"
echo "Created disabled config scaffold: ${CONFIG_PATH}"
else
echo "Config already exists, left unchanged: ${CONFIG_PATH}"
fi
echo "Installed Hermes plugin: ${PLUGIN_DST}"
echo "Opt in by editing ${CONFIG_PATH}: set scout.enabled=true and scout.tenant_id to this desktop tenant."

View file

@ -0,0 +1,537 @@
"""Hermes plugin for M2 Solution Scout v0.
The plugin is intentionally fail-closed: no policy opt-in means no hooks do
anything beyond loading local config, and raw-source deny keys refuse startup.
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import json
import os
import random
import re
import shutil
import socket
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote
POLICY_PATH = Path("~/.config/m2-market/pull-policy.toml").expanduser()
STATE_DIR = Path("~/.local/share/m2-market/scout").expanduser()
STATE_PATH = STATE_DIR / "state.json"
OUTBOX_DIR = STATE_DIR / "outbox"
_SECRET_PATTERNS = [
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
re.compile(r"(?i)\b(api[_ -]?key|secret|token)\s*[:=]\s*['\"]?[^'\"\s]{8,}"),
]
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{2,}")
class PolicyError(RuntimeError):
"""Policy explicitly requests an unsafe mode."""
def _utcnow() -> _dt.datetime:
return _dt.datetime.now(_dt.timezone.utc)
def _iso(ts: _dt.datetime | None = None) -> str:
return (ts or _utcnow()).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _parse_scalar(value: str) -> Any:
value = value.strip()
if not value or value.startswith("#"):
return ""
if "#" in value and not value.lstrip().startswith(("'", '"')):
value = value.split("#", 1)[0].strip()
if value in {"true", "false"}:
return value == "true"
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
return value[1:-1]
if value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
if not inner:
return []
return [_parse_scalar(part.strip()) for part in inner.split(",")]
try:
if "." in value:
return float(value)
return int(value)
except ValueError:
return value
def parse_toml(text: str) -> dict[str, Any]:
"""Tiny TOML subset parser, with optional tomli avoided for Python 3.10 hosts."""
data: dict[str, Any] = {}
current = data
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
current = data
for part in line[1:-1].split("."):
current = current.setdefault(part.strip(), {})
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
current[key.strip()] = _parse_scalar(value)
return data
def _read_toml(path: Path) -> dict[str, Any]:
try:
import tomli # type: ignore
except Exception:
tomli = None
if not path.exists():
return {}
raw = path.read_bytes()
if tomli is not None:
return tomli.loads(raw.decode("utf-8"))
return parse_toml(raw.decode("utf-8"))
def _section(data: dict[str, Any], *names: str) -> dict[str, Any]:
node: Any = data
for name in names:
if not isinstance(node, dict):
return {}
node = node.get(name, {})
return node if isinstance(node, dict) else {}
def load_policy(path: Path = POLICY_PATH) -> dict[str, Any]:
raw = _read_toml(path)
scout = _section(raw, "scout")
sources = _section(raw, "scout", "sources")
summary = _section(raw, "scout", "summary")
catalog = _section(raw, "scout", "catalog")
store = _section(raw, "scout", "store")
telemetry = _section(raw, "scout", "telemetry")
enabled = bool(scout.get("enabled", False))
tenant_id = str(scout.get("tenant_id", "") or "").strip()
if sources.get("raw_keystrokes") is True or sources.get("raw_client_data") is True:
raise PolicyError("Solution Scout refuses raw_keystrokes/raw_client_data policy")
if not enabled or not tenant_id:
return {"enabled": False, "reason": "disabled_or_missing_tenant"}
return {
"enabled": True,
"mode": scout.get("mode", "suggest"),
"desktop_id": str(scout.get("desktop_id", "unknown-desktop")),
"operator_id": str(scout.get("operator_id", "unknown-operator")),
"tenant_id": tenant_id,
"min_score": float(scout.get("min_score", 0.78)),
"min_coverage": float(scout.get("min_coverage", 0.55)),
"max_proposals_per_day": int(scout.get("max_proposals_per_day", 5)),
"max_proposals_per_session": int(scout.get("max_proposals_per_session", 1)),
"dismiss_cooldown_days": int(scout.get("dismiss_cooldown_days", 14)),
"proposal_cooldown_minutes": int(scout.get("proposal_cooldown_minutes", 45)),
"summary": {
"max_source_chars": int(summary.get("max_source_chars", 12000)),
"max_summary_chars": int(summary.get("max_summary_chars", 900)),
"redact_secrets": bool(summary.get("redact_secrets", True)),
"redact_client_identifiers": bool(summary.get("redact_client_identifiers", True)),
"fail_closed_on_secret": bool(summary.get("fail_closed_on_secret", True)),
},
"catalog": {
"memory_api_url": str(catalog.get("memory_api_url", "https://memory.machinemachine.ai")),
"agent_id": str(catalog.get("agent_id", "market:catalog")),
"limit": int(catalog.get("limit", 5)),
"routing_strategy": str(catalog.get("routing_strategy", "standard")),
},
"store": {
"deeplink_scheme": str(store.get("deeplink_scheme", "m2store")),
"fallback_command": str(store.get("fallback_command", "m2-market show --json")),
},
"telemetry": {"enabled": bool(telemetry.get("enabled", True))},
}
def redact(text: str, *, redact_client_identifiers: bool = True) -> tuple[str, dict[str, bool]]:
found_secret = False
redacted = text
for pattern in _SECRET_PATTERNS:
redacted, count = pattern.subn("[REDACTED_SECRET]", redacted)
found_secret = found_secret or count > 0
found_client = False
if redact_client_identifiers:
redacted, count = _EMAIL_RE.subn("[REDACTED_EMAIL]", redacted)
found_client = count > 0
return redacted, {
"secrets_redacted": found_secret,
"client_identifiers_redacted": found_client,
"failed_closed": False,
}
def summarize_turn(user_message: str, assistant_response: str, policy: dict[str, Any]) -> dict[str, Any]:
summary_policy = policy["summary"]
source = (user_message + "\n" + assistant_response)[: summary_policy["max_source_chars"]]
clean, scrub = redact(
source,
redact_client_identifiers=summary_policy.get("redact_client_identifiers", True),
)
sentences = re.split(r"(?<=[.!?])\s+", " ".join(clean.split()))
summary = " ".join(sentences[:3]).strip()
if len(summary) > summary_policy["max_summary_chars"]:
summary = summary[: summary_policy["max_summary_chars"]].rsplit(" ", 1)[0].strip()
terms = []
for word in _WORD_RE.findall(summary.lower()):
if word in {"the", "and", "with", "this", "that", "from", "have", "will", "into"}:
continue
if word not in terms:
terms.append(word)
if len(terms) >= 12:
break
intent_hash = "sha256:" + hashlib.sha256(summary.encode("utf-8")).hexdigest()
return {
"schema_version": "m2.scout.intent_summary.v1",
"summary_id": _id("sum", policy),
"observation_ids": [],
"desktop_id": policy["desktop_id"],
"operator_id": policy["operator_id"],
"tenant_id": policy["tenant_id"],
"session_id": "",
"summary": summary,
"intent_terms": terms,
"negative_terms": [],
"scrub_status": scrub,
"intent_hash": intent_hash,
"created_at": _iso(),
}
def _id(prefix: str, policy: dict[str, Any]) -> str:
stamp = _utcnow().strftime("%Y%m%d")
desktop = re.sub(r"[^A-Za-z0-9]+", "", policy.get("desktop_id", "desktop"))[:12] or "desktop"
return f"{prefix}_{stamp}_{desktop}_{random.randrange(36**4):04x}"
def _load_state(path: Path = STATE_PATH) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"daily": {}, "sessions": {}, "dismissed": {}, "last_proposal_at": None}
def _save_state(state: dict[str, Any], path: Path = STATE_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, sort_keys=True, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
def gate(policy: dict[str, Any], state: dict[str, Any], *, session_id: str = "") -> tuple[bool, str | None]:
if not policy.get("enabled"):
return False, "disabled"
today = _utcnow().date().isoformat()
daily_count = int(state.get("daily", {}).get(today, 0))
if daily_count >= policy["max_proposals_per_day"]:
return False, "rate_capped"
if session_id:
session_count = int(state.get("sessions", {}).get(session_id, 0))
if session_count >= policy["max_proposals_per_session"]:
return False, "session_capped"
last = state.get("last_proposal_at")
if last:
try:
last_ts = _dt.datetime.fromisoformat(str(last).replace("Z", "+00:00"))
cooldown = _dt.timedelta(minutes=policy["proposal_cooldown_minutes"])
if _utcnow() - last_ts < cooldown:
return False, "proposal_cooldown"
except ValueError:
pass
return True, None
def _memory_api_key() -> str:
if os.environ.get("M2_MEMORY_API_KEY"):
return os.environ["M2_MEMORY_API_KEY"]
config = _read_toml(Path("~/.m2-market/config.toml").expanduser())
memory = _section(config, "memory")
for key in ("api_key", "memory_api_key", "token"):
value = memory.get(key) or config.get(key)
if value:
return str(value)
return ""
def _visible(listing: dict[str, Any], tenant_id: str) -> bool:
visibility = listing.get("tenant_visibility") or []
status = listing.get("status", "published")
return status == "published" and ("*" in visibility or tenant_id in visibility)
def search_catalog(summary: dict[str, Any], policy: dict[str, Any]) -> list[dict[str, Any]]:
import httpx
catalog = policy["catalog"]
headers = {}
api_key = _memory_api_key()
if api_key:
headers["X-API-Key"] = api_key
with httpx.Client(base_url=catalog["memory_api_url"], headers=headers, timeout=10.0) as client:
response = client.post(
"/memory/search",
json={
"query": summary["summary"],
"agent_id": catalog["agent_id"],
"routing_strategy": catalog["routing_strategy"],
"limit": max(catalog["limit"] * 3, catalog["limit"]),
},
)
response.raise_for_status()
items = []
for result in response.json().get("results", []):
meta = result.get("metadata") or {}
listing = meta.get("listing") or {}
listing_id = meta.get("listing_id") or listing.get("listing_id") or listing.get("id")
if not listing_id or not _visible(listing, policy["tenant_id"]):
continue
score = float(result.get("score") or 0.0)
coverage = _estimate_coverage(summary, listing)
if score < policy["min_score"] or coverage < policy["min_coverage"]:
continue
items.append(
{
"listing_id": listing_id,
"solution_id": listing.get("solution_id", ""),
"score": score,
"coverage": coverage,
"listing": listing,
}
)
if len(items) >= catalog["limit"]:
break
return items
def _estimate_coverage(summary: dict[str, Any], listing: dict[str, Any]) -> float:
terms = set(summary.get("intent_terms") or [])
haystack = " ".join(
str(listing.get(k, "")) for k in ("name", "summary", "description", "category")
).lower()
keywords = listing.get("keywords") or []
if isinstance(keywords, list):
haystack += " " + " ".join(str(k).lower() for k in keywords)
if not terms:
return 0.0
hits = sum(1 for term in terms if term in haystack)
return round(min(1.0, hits / max(3, len(terms))), 2)
def build_proposal(match: dict[str, Any], summary: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any]:
listing = match["listing"]
proposal_id = _id("prp", policy)
match_id = _id("mat", policy)
listing_id = match["listing_id"]
deeplink = (
f"{policy['store']['deeplink_scheme']}://listing/{quote(listing_id)}"
f"?proposal_id={quote(proposal_id)}&source=scout"
)
now = _utcnow()
return {
"schema_version": "m2.scout.proposal.v1",
"proposal_id": proposal_id,
"match_id": match_id,
"desktop_id": policy["desktop_id"],
"operator_id": policy["operator_id"],
"tenant_id": policy["tenant_id"],
"listing": {
"listing_id": listing_id,
"name": listing.get("name", listing_id),
"summary": listing.get("summary", ""),
"price": listing.get("price", {}),
"stats": listing.get("stats", {}),
},
"confidence": match["score"],
"coverage": match["coverage"],
"deeplink": deeplink,
"shown_at": _iso(now),
"expires_at": _iso(now + _dt.timedelta(minutes=policy["proposal_cooldown_minutes"])),
"_telemetry": {
"session_id": summary.get("session_id", ""),
"intent_hash": summary["intent_hash"],
"solution_id": match.get("solution_id", ""),
},
}
def write_proposal_shown(proposal: dict[str, Any], outbox_dir: Path = OUTBOX_DIR) -> dict[str, Any]:
telemetry = proposal.pop("_telemetry", {})
event = {
"schema_version": "m2.scout.telemetry.v1",
"event_id": _id("evt", proposal),
"event_type": "proposal_shown",
"proposal_id": proposal["proposal_id"],
"listing_id": proposal["listing"]["listing_id"],
"solution_id": telemetry.get("solution_id", ""),
"operator_id": proposal["operator_id"],
"desktop_id": proposal["desktop_id"],
"tenant_id": proposal["tenant_id"],
"session_id": telemetry.get("session_id", ""),
"intent_hash": telemetry.get("intent_hash", ""),
"score": proposal["confidence"],
"coverage": proposal["coverage"],
"reason": None,
"created_at": proposal["shown_at"],
"proposal": proposal,
}
outbox_dir.mkdir(parents=True, exist_ok=True)
path = outbox_dir / f"{_utcnow().date().isoformat()}.jsonl"
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(event, sort_keys=True) + "\n")
return event
def notify(proposal: dict[str, Any]) -> None:
listing = proposal["listing"]
price = listing.get("price") or {}
amount = price.get("amount", "?")
currency = price.get("currency", "m2cr")
body = (
f"This looks like {listing['name']}: ~{int(proposal['coverage'] * 100)}% coverage, "
f"{amount} {currency}. Open in M2 Store?"
)
if shutil.which("notify-send"):
try:
subprocess.Popen(
[
"notify-send",
"M2 Solution Scout",
body,
f"--action=open=Open",
f"--action=dismiss=Dismiss",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
_herdr_toast(body, proposal["deeplink"])
def _herdr_toast(message: str, deeplink: str) -> None:
socket_path = os.environ.get("HERDR_SOCKET_PATH", "").strip()
pane_id = os.environ.get("HERDR_PANE_ID", "").strip()
if not socket_path or not pane_id:
return
request = {
"id": f"m2-solution-scout:{int(time.time() * 1000)}",
"method": "pane.toast",
"params": {
"pane_id": pane_id,
"source": "m2-solution-scout",
"title": "M2 Solution Scout",
"message": message,
"deeplink": deeplink,
},
}
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(0.5)
client.connect(socket_path)
client.sendall((json.dumps(request) + "\n").encode("utf-8"))
client.close()
except Exception:
pass
def _bump_state(
state: dict[str, Any], policy: dict[str, Any], session_id: str, proposal: dict[str, Any]
) -> None:
today = _utcnow().date().isoformat()
state.setdefault("daily", {})[today] = int(state.setdefault("daily", {}).get(today, 0)) + 1
if session_id:
state.setdefault("sessions", {})[session_id] = int(
state.setdefault("sessions", {}).get(session_id, 0)
) + 1
state["last_proposal_at"] = _iso()
state["last_proposal"] = {
"proposal_id": proposal["proposal_id"],
"listing_id": proposal["listing"]["listing_id"],
"cooldown_until": proposal["expires_at"],
}
state.setdefault("proposals", []).append(state["last_proposal"])
_save_state(state)
def _extract_turn(kwargs: dict[str, Any]) -> tuple[str, str, str]:
user_message = str(kwargs.get("user_message") or kwargs.get("prompt") or "")
assistant_response = str(kwargs.get("assistant_response") or kwargs.get("response") or "")
if not user_message:
messages = kwargs.get("messages")
if isinstance(messages, list):
user_message = "\n".join(
str(m.get("content", "")) for m in messages if isinstance(m, dict) and m.get("role") == "user"
)
session_id = str(kwargs.get("session_id") or "")
return user_message, assistant_response, session_id
class ScoutRuntime:
def __init__(self) -> None:
self.policy: dict[str, Any] = {"enabled": False}
self._lock = threading.Lock()
def on_session_start(self, **kwargs: Any) -> None:
del kwargs
self.policy = load_policy()
def post_llm_call(self, **kwargs: Any) -> None:
user_message, assistant_response, session_id = _extract_turn(kwargs)
if not user_message and not assistant_response:
return
state = _load_state()
ok, _reason = gate(self.policy, state, session_id=session_id)
if not ok:
return
worker = threading.Thread(
target=self._process,
args=(user_message, assistant_response, session_id, state),
daemon=True,
)
worker.start()
def _process(
self, user_message: str, assistant_response: str, session_id: str, state: dict[str, Any]
) -> None:
with self._lock:
try:
summary = summarize_turn(user_message, assistant_response, self.policy)
summary["session_id"] = session_id
matches = search_catalog(summary, self.policy)
if not matches:
return
proposal = build_proposal(matches[0], summary, self.policy)
write_proposal_shown(proposal)
notify(proposal)
_bump_state(state, self.policy, session_id, proposal)
except Exception:
return
_RUNTIME = ScoutRuntime()
def register(ctx: Any) -> None:
ctx.register_hook("on_session_start", _RUNTIME.on_session_start)
ctx.register_hook("post_llm_call", _RUNTIME.post_llm_call)

View file

@ -0,0 +1,6 @@
name: m2-solution-scout
version: "0.1"
description: Solution Scout intent watch
hooks:
- on_session_start
- post_llm_call

View file

@ -0,0 +1,42 @@
[scout]
enabled = false
mode = "suggest"
desktop_id = "chris-m2o"
operator_id = "op_chris"
tenant_id = ""
min_score = 0.60
min_coverage = 0.30
poll_interval_seconds = 90
max_proposals_per_day = 5
max_proposals_per_session = 1
dismiss_cooldown_days = 14
proposal_cooldown_minutes = 45
[scout.sources]
herdr_runs = true
session_summaries = true
window_titles = false
raw_keystrokes = false
raw_client_data = false
[scout.summary]
max_source_chars = 12000
max_summary_chars = 900
redact_secrets = true
redact_client_identifiers = true
fail_closed_on_secret = true
[scout.catalog]
memory_api_url = "https://memory.machinemachine.ai"
agent_id = "market:catalog"
limit = 5
routing_strategy = "standard"
[scout.store]
deeplink_scheme = "m2store"
fallback_command = "m2-market show --json"
[scout.telemetry]
enabled = true
agent_id = "market:evidence"
batch_seconds = 60

186
scout/tests/test_scout.py Normal file
View file

@ -0,0 +1,186 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import httpx
import pytest
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "m2-solution-scout" / "__init__.py"
@pytest.fixture()
def scout(monkeypatch, tmp_path):
spec = importlib.util.spec_from_file_location("m2_solution_scout_test", PLUGIN)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
assert spec.loader is not None
spec.loader.exec_module(module)
monkeypatch.setattr(module, "STATE_PATH", tmp_path / "state.json")
monkeypatch.setattr(module, "STATE_DIR", tmp_path)
monkeypatch.setattr(module, "OUTBOX_DIR", tmp_path / "outbox")
return module
def policy(scout):
return {
"enabled": True,
"desktop_id": "chris-m2o",
"operator_id": "op_chris",
"tenant_id": "machine-machine",
"min_score": 0.78,
"min_coverage": 0.2,
"max_proposals_per_day": 5,
"max_proposals_per_session": 1,
"proposal_cooldown_minutes": 45,
"summary": {
"max_source_chars": 12000,
"max_summary_chars": 120,
"redact_client_identifiers": True,
},
"catalog": {
"memory_api_url": "https://memory.test",
"agent_id": "market:catalog",
"limit": 5,
"routing_strategy": "standard",
},
"store": {"deeplink_scheme": "m2store"},
}
def test_gate_disabled_opted_out_rate_capped_and_cooldown_noop(scout):
enabled = policy(scout)
assert scout.gate({"enabled": False}, {}, session_id="s1") == (False, "disabled")
assert scout.load_policy(Path("/no/such/file")) == {
"enabled": False,
"reason": "disabled_or_missing_tenant",
}
today = scout._utcnow().date().isoformat()
assert scout.gate(
enabled,
{"daily": {today: 5}, "sessions": {}, "last_proposal_at": None},
session_id="s1",
) == (False, "rate_capped")
assert scout.gate(
enabled,
{"daily": {}, "sessions": {"s1": 1}, "last_proposal_at": None},
session_id="s1",
) == (False, "session_capped")
assert scout.gate(
enabled,
{"daily": {}, "sessions": {}, "last_proposal_at": scout._iso()},
session_id="s1",
) == (False, "proposal_cooldown")
def test_policy_refuses_raw_sources(scout, tmp_path):
path = tmp_path / "pull-policy.toml"
path.write_text(
"""
[scout]
enabled = true
tenant_id = "machine-machine"
[scout.sources]
raw_keystrokes = true
raw_client_data = false
""",
encoding="utf-8",
)
with pytest.raises(scout.PolicyError):
scout.load_policy(path)
def test_redaction_and_summary_are_bounded(scout):
text = (
"Use sk-abcdefghijklmnopqrstuvwxyz and API_KEY=supersecretvalue for jane@example.com. "
"Build a competitor research report with sources and PDF output."
)
summary = scout.summarize_turn(text, "Prepare the report.", policy(scout))
assert "sk-" not in summary["summary"]
assert "supersecretvalue" not in summary["summary"]
assert "jane@example.com" not in summary["summary"]
assert "[REDACTED_SECRET]" in summary["summary"]
assert len(summary["summary"]) <= 120
assert summary["schema_version"] == "m2.scout.intent_summary.v1"
def test_proposal_outbox_record_shape_matches_schema(scout, tmp_path):
p = policy(scout)
summary = scout.summarize_turn("competitor research report pdf", "", p)
match = {
"listing_id": "lst_competitor-scan",
"solution_id": "sol_competitor-scan",
"score": 0.84,
"coverage": 0.72,
"listing": {
"name": "Competitor Scan",
"summary": "Research report package.",
"price": {"amount": 40, "currency": "m2cr", "model": "fixed"},
"stats": {"installs": 12, "rating": 4.6},
},
}
proposal = scout.build_proposal(match, summary, p)
event = scout.write_proposal_shown(proposal, tmp_path / "outbox")
assert event["event_type"] == "proposal_shown"
assert event["proposal"]["schema_version"] == "m2.scout.proposal.v1"
assert event["proposal"]["listing"]["listing_id"] == "lst_competitor-scan"
assert event["proposal"]["deeplink"].startswith("m2store://listing/lst_competitor-scan")
lines = list((tmp_path / "outbox").glob("*.jsonl"))[0].read_text(encoding="utf-8").splitlines()
assert json.loads(lines[0])["proposal_id"] == event["proposal_id"]
def test_mocked_catalog_search_filters_visibility_and_posts_live_shape(scout, monkeypatch):
requests = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(
200,
json={
"results": [
{
"score": 0.91,
"metadata": {
"listing_id": "lst_competitor-scan",
"listing": {
"name": "Competitor Scan",
"summary": "Competitor research report with PDF output",
"keywords": ["competitor", "research", "report"],
"tenant_visibility": ["*"],
"status": "published",
},
},
},
{
"score": 0.99,
"metadata": {
"listing_id": "lst_private",
"listing": {
"name": "Private",
"tenant_visibility": ["other-tenant"],
"status": "published",
},
},
},
],
"routing": {},
},
)
original_init = httpx.Client.__init__
def patched_init(self, *args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
original_init(self, *args, **kwargs)
monkeypatch.setattr(httpx.Client, "__init__", patched_init)
summary = scout.summarize_turn("competitor research report pdf", "", policy(scout))
results = scout.search_catalog(summary, policy(scout))
assert requests[0]["agent_id"] == "market:catalog"
assert requests[0]["query"] == summary["summary"]
assert "tenant_id" not in requests[0]
assert [item["listing_id"] for item in results] == ["lst_competitor-scan"]

View file

@ -0,0 +1,26 @@
{
"schema_version": "m2.listing.v1",
"listing_id": "lst_agent-scaffold",
"solution_id": "sol_agent-scaffold",
"solution_version": "1.0.0",
"inventory_type": "solution",
"name": "Agent Scaffold Generator",
"summary": "Scaffold OpenClaw/Hermes agent workspaces (PRD.md + SOUL.md + MEMORY.md) from m2-memory context.",
"category": "agent-tooling",
"keywords": ["agent", "scaffold", "openclaw", "hermes", "prd", "soul", "memory"],
"price": {
"amount": 60,
"currency": "m2cr",
"model": "fixed"
},
"seller": "sdjs-operator",
"evidence_summary": "Skill in production use on the m2 host since 2026-04-20, extracted from a real workflow: four PRD.md/SOUL.md agent workspaces at /home/m2/gmi-clinic/agents/ (GMI Clinic fleet deployment) created within a minute of SKILL.md's own mtime, plus three more SOUL.md digital-twin identities at /home/m2/agent-restore-harness/agents/ from 2026-04-22/23 confirming continued production use. Requires buyer-side memory-api access for full function.",
"tenant_visibility": ["*"],
"stats": {
"installs": 0,
"proposals_shown": 0,
"proposals_accepted": 0
},
"status": "draft",
"install_ref": "lst_agent-scaffold-v1.0.0"
}

View file

@ -0,0 +1,41 @@
---
name: agent-scaffold
description: Scaffolds OpenClaw/Hermes agent workspaces from m2-memory. Query memory for context, generate PRD.md + SOUL.md for a new agent, and optionally wire into the fleet.
---
# agent-scaffold
Generate a complete agent workspace (PRD + SOUL + MEMORY seed) from context in m2-memory.
## Usage
```
/agent-scaffold <agent-name> "<what this agent does>"
```
Examples:
```
/agent-scaffold gesy-rates-fetcher "Fetches and monitors GESY reimbursement rate updates for GMI Clinic"
/agent-scaffold platform-unifier "Maps and unifies the 3-5 GMI Clinic software platforms into Machine.Machine"
/agent-scaffold nasr-research-runner "Runs parallel deep research tasks for Nasr's healthcare domain work"
```
## What it produces
For each agent, the skill:
1. **Queries m2-memory** for relevant context (entity/project history, related agents, decisions)
2. **Generates `PRD.md`** — problem, personas, functional requirements, success metrics, open items
3. **Generates `SOUL.md`** — agent identity, values, communication style, scope boundaries
4. **Generates `MEMORY.md`** — seed memories pre-loaded from m2-memory search results
5. **Prints a deploy snippet**`docker run` or Coolify env vars to spawn the agent
## Output location
`~/agents/<agent-name>/` — commit to `git.machinemachine.ai/machine.machine/specs/` when ready.
## Design principle
The goal is reproducibility: if an agent's context is lost (like Nasr's Apr 8 research agents),
this skill can reconstruct the workspace from the vector store and hand it to OpenClaw or Hermes
to re-execute without starting from scratch.

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
agent-scaffold generate PRD.md + SOUL.md + MEMORY.md for a new agent
from m2-memory context.
Usage:
python3 scaffold.py <agent-name> "<description>"
python3 scaffold.py gesy-rates-fetcher "Monitors GESY rates for GMI Clinic DRG billing"
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.request
from pathlib import Path
MEMORY_API = os.environ.get("M2_MEMORY_API_URL", "http://172.18.0.20:8000")
AGENT_ID = os.environ.get("M2_MEMORY_AGENT_ID", "m2")
API_KEY = os.environ.get("M2_MEMORY_API_KEY")
OUT_BASE = Path.home() / "agents"
def search_memory(query: str, limit: int = 10) -> list[dict]:
url = f"{MEMORY_API}/memory/search"
body = json.dumps({
"query": query,
"agent_id": AGENT_ID,
"routing_strategy": "deep",
"limit": limit,
}).encode()
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["X-API-Key"] = API_KEY
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read()).get("results", [])
def fmt_memory_block(results: list[dict]) -> str:
lines = []
for r in results:
ts = (r.get("timestamp") or "")[:10]
score = r.get("score", 0)
content = (r.get("content") or "").replace("\n", " ")[:200]
lines.append(f"- [{ts} score={score:.2f}] {content}")
return "\n".join(lines)
def generate_prd(name: str, description: str, memories: list[dict]) -> str:
mem_block = fmt_memory_block(memories)
return f"""# {name.replace("-", " ").title()} — PRD
**Agent ID:** {name}
**Description:** {description}
**Generated from:** m2-memory search ({len(memories)} relevant memories)
---
## Problem
> TODO: Refine based on memory context below
{description}
## Relevant Memory Context
{mem_block}
## Personas
| Persona | Goal |
|---------|------|
| TODO | TODO |
## Functional Requirements
1. TODO derived from memory context above
2.
3.
## Success Metrics
- TODO
## Open Items
- TODO check memory for unresolved gaps
## Technical Notes
- Agent runtime: OpenClaw / Hermes
- Memory: m2-memory (agent_id={name})
- Deploy: Coolify stack on Machine.Machine fleet
"""
def generate_soul(name: str, description: str) -> str:
title = name.replace("-", " ").title()
return f"""# {title} — Agent Identity
You are **{title}**, part of the Machine.Machine agent fleet.
{description}
## Values
- TODO: define 3 core values for this agent
## Communication style
- TODO: define tone and output format
## Scope boundaries
- You do not TODO
- You do not TODO
"""
def generate_memory_seed(memories: list[dict]) -> str:
lines = ["# Seed Memories\n",
"These memories are pre-loaded from m2-memory at agent creation time.\n"]
for r in memories[:10]:
ts = (r.get("timestamp") or "")[:19]
mtype = r.get("memory_type", "semantic")
content = (r.get("content") or "").strip()[:500]
lines.append(f"## [{mtype} {ts}]\n{content}\n")
return "\n".join(lines)
def main():
p = argparse.ArgumentParser()
p.add_argument("name", help="agent slug (e.g. gesy-rates-fetcher)")
p.add_argument("description", help="one-line description of what the agent does")
p.add_argument("--out", default=None, help="output directory (default: ~/agents/<name>)")
p.add_argument("--limit", type=int, default=10, help="memory search limit")
args = p.parse_args()
out_dir = Path(args.out) if args.out else OUT_BASE / args.name
out_dir.mkdir(parents=True, exist_ok=True)
print(f"Searching m2-memory for: {args.description}", file=sys.stderr)
try:
memories = search_memory(args.description, args.limit)
print(f" Found {len(memories)} relevant memories", file=sys.stderr)
except Exception as e:
print(f" Memory search failed ({e}) — generating without context", file=sys.stderr)
memories = []
(out_dir / "PRD.md").write_text(generate_prd(args.name, args.description, memories))
(out_dir / "SOUL.md").write_text(generate_soul(args.name, args.description))
(out_dir / "MEMORY.md").write_text(generate_memory_seed(memories))
print(f"\nScaffolded: {out_dir}")
print(f" PRD.md — {(out_dir / 'PRD.md').stat().st_size} bytes")
print(f" SOUL.md — {(out_dir / 'SOUL.md').stat().st_size} bytes")
print(f" MEMORY.md — {(out_dir / 'MEMORY.md').stat().st_size} bytes")
print(f"\nNext: review + fill TODOs, then commit to git.machinemachine.ai/machine.machine/specs/")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,13 @@
# agent-scaffold install recipe (apply-adapter.md bundle layout, local adapter v1)
# Places the agent-scaffold skill into the buyer's Claude Code skills directory.
targets:
- src: SKILL.md
dest: ~/.claude/skills/agent-scaffold/SKILL.md
- src: scripts/scaffold.py
dest: ~/.claude/skills/agent-scaffold/scripts/scaffold.py
checks:
- cmd: test -f ~/.claude/skills/agent-scaffold/SKILL.md
expect_exit: 0
- cmd: test -x ~/.claude/skills/agent-scaffold/scripts/scaffold.py
expect_exit: 0
entrypoint: "Ask your agent to use the agent-scaffold skill, or run: python3 ~/.claude/skills/agent-scaffold/scripts/scaffold.py <agent-name> \"<description>\""

View file

@ -0,0 +1,98 @@
{
"schema_version": "m2.solution.v1",
"solution_id": "sol_agent-scaffold",
"name": "Agent Scaffold Generator",
"summary": "Scaffold OpenClaw/Hermes agent workspaces (PRD.md + SOUL.md + MEMORY.md) from m2-memory context.",
"description": "Installs the agent-scaffold skill: given an agent name and a one-line description, it queries m2-memory (agent.memory.system / memory-api) for relevant context and generates a complete agent workspace — PRD.md (problem, personas, functional requirements, success metrics), SOUL.md (identity, values, communication style, scope boundaries), and MEMORY.md (seed memories pre-loaded from the search results) — plus a deploy snippet for OpenClaw or Hermes. Ships two artifacts: SKILL.md and scripts/scaffold.py (a single-file Python script using only the stdlib, talking HTTP to memory-api). Requirement: the buyer machine needs memory-api (m2-memory) network access for full function — scripts/scaffold.py calls M2_MEMORY_API_URL (default http://172.18.0.20:8000, override via env) at /memory/search. Without reachable memory-api the script still runs and produces PRD.md/SOUL.md, but with an empty 'Relevant Memory Context' section and no MEMORY.md content — degraded, not blocked. content_hash is computed over payload/ + recipe.yaml only (not solution.json itself, to avoid the self-referential hash problem of hashing a file that contains its own hash): sha256 of `tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner -czf - payload recipe.yaml` run from this bundle's root.",
"intent": "Give an operator a reproducible way to stand up (or reconstruct) a new agent's working context — PRD + identity + seed memory — from what the fleet's memory system already knows, instead of starting from a blank page.",
"behavior": {
"skill_ref": "payload/SKILL.md"
},
"tools": [
{
"name": "memory-api",
"kind": "http-api",
"required": true
},
{
"name": "python3",
"kind": "cli",
"required": true
}
],
"runtime": {
"surfaces": [
"claude-code-skill"
]
},
"permissions": [
{
"action": "write",
"target": "~/.claude/skills/agent-scaffold/"
},
{
"action": "write",
"target": "~/agents/<agent-name>/ (PRD.md, SOUL.md, MEMORY.md — output of running the skill, not part of the install)"
},
{
"action": "net",
"target": "HTTP POST to the configured M2_MEMORY_API_URL /memory/search endpoint"
}
],
"deployment": {
"recipe_ref": "recipe.yaml",
"entrypoint": "Ask your agent to use the agent-scaffold skill, or run: python3 ~/.claude/skills/agent-scaffold/scripts/scaffold.py <agent-name> \"<description>\"",
"verify_command": "test -f ~/.claude/skills/agent-scaffold/SKILL.md"
},
"applicability": {
"image_classes": [
"primus",
"agent-latest"
],
"roles": [
"operator",
"desktop-agent"
],
"tool_requirements": [
"python3",
"memory-api"
]
},
"tenant_scope": "m2-core",
"evidence": [
{
"source": "/home/m2/.claude/skills/agent-scaffold/ (SKILL.md, scripts/scaffold.py) — the proven skill this bundle packages, present on the m2 host since 2026-04-20 (SKILL.md mtime 2026-04-20T22:48:00+02:00, scripts/scaffold.py mtime 2026-04-28T00:33:10+02:00 — a later revision of the same script)",
"machine": "m2",
"excerpt": "SKILL.md frontmatter: 'Scaffolds OpenClaw/Hermes agent workspaces from m2-memory. Query memory for context, generate PRD.md + SOUL.md for a new agent, and optionally wire into the fleet.'"
},
{
"source": "/home/m2/gmi-clinic/agents/{admin-data-bridge,clinical-entity-extraction,drg-los-optimizer}/{PRD.md,SOUL.md} — three real PRD+SOUL agent-workspace pairs on disk, all created 2026-04-20 22:46:2722:47:19 (mtimes), roughly one minute before this skill's own SKILL.md mtime (22:48:00) — the exact PRD.md/SOUL.md workspace shape this skill formalizes and automates, produced for the GMI Clinic AI fleet deployment immediately prior to the skill being written",
"machine": "m2",
"excerpt": "admin-data-bridge/SOUL.md: 'You are the Admin Data Bridge for GMI Clinic... ## Values ## Communication style ## Scope boundaries' — same section structure generate_soul() in scaffold.py emits"
},
{
"source": "/home/m2/gmi-clinic/agents/gesy-research/PRD.md — a fourth real workspace, mtime 2026-04-20T22:47:48+02:00, whose own text names the exact incident SKILL.md's 'Design principle' section cites as the skill's reason for existing: 'On 2026-04-08, Nasr ran 4 parallel research agents on his machine... The output was intended to be stored in m2-memory, Planka, and Forgejo but the storage step failed (m2 errored). This PRD reconstructs the agent's purpose and gaps so the research can be completed and properly stored.'",
"machine": "m2",
"excerpt": "SKILL.md: 'The goal is reproducibility: if an agent's context is lost (like Nasr's Apr 8 research agents), this skill can reconstruct the workspace from the vector store and hand it to OpenClaw or Hermes to re-execute without starting from scratch.'"
},
{
"source": "/home/m2/agent-restore-harness/agents/*/SOUL.md — three further real SOUL.md agent-identity files for actual named fleet agents (machine names withheld from shared catalog per tenant-firewall hygiene), mtimes 2026-04-22T23:412026-04-23T00:48, confirming the PRD+SOUL workspace pattern stayed in production use after the skill was written, not a one-off",
"machine": "m2",
"excerpt": "nasr/SOUL.md: 'You are Nasr's digital twin — his AI counterpart in the Machine.Machine fleet... ## Core Capabilities'"
}
],
"content_hash": "sha256:202aaff5519e774e2669bd2ca6220fc67284a506775a90ff7710fb2361cc58ee",
"price": {
"amount": 60,
"currency": "m2cr",
"model": "fixed"
},
"seller": "sdjs-operator",
"license": {
"terms": "Perpetual use on operator-owned machines for the buyer's own agent-workspace generation; no redistribution of the skill files as a standalone product.",
"major_version_coverage": true
},
"revenue_split": {
"platform_pct": 10
}
}

View file

@ -0,0 +1,26 @@
{
"schema_version": "m2.listing.v1",
"listing_id": "lst_mm-pdf-report",
"solution_id": "sol_mm-pdf-report",
"solution_version": "1.0.0",
"inventory_type": "solution",
"name": "MM PDF Report Generator",
"summary": "Generate Machine.Machine branded PDF reports and slide decks from Markdown.",
"category": "document-generation",
"keywords": ["pdf", "branding", "markdown", "reports", "decks"],
"price": {
"amount": 40,
"currency": "m2cr",
"model": "fixed"
},
"seller": "sdjs-operator",
"evidence_summary": "Skill in production use on the m2 host since 2026-04-21; two real generated PDF/HTML pairs on disk (session-2026-04-20.pdf, spark-cluster-old/fabric-setup.pdf) confirm the pipeline works end-to-end.",
"tenant_visibility": ["*"],
"stats": {
"installs": 0,
"proposals_shown": 0,
"proposals_accepted": 0
},
"status": "draft",
"install_ref": "lst_mm-pdf-report-v1.0.0"
}

View file

@ -0,0 +1,89 @@
---
name: mm-pdf
description: Generate beautifully styled Machine.Machine branded PDFs and slide decks from Markdown. Dark navy aesthetic, cyan accents, professional A4 layout. Uses a headless-Chrome render container (any container with Node + Chrome, e.g. a "*-m2o" agent-desktop image) or a local WeasyPrint fallback. Produces PDF + HTML output.
---
# mm-pdf
Generate MM-branded documents from Markdown content.
## Design system
| Token | Value | Use |
|-------|-------|-----|
| Cover background | `#0a0e1a` | Dark navy |
| Primary accent | `#4bb8d4` | Cyan — headings, kickers, borders |
| Section banners | `#3d4550` | Slate grey |
| Purple sidebar | `#6b46c1` | Alt cover variant |
| Body text | `#e2e8f0` | Light grey on dark bg |
| Body background | `#111827` | Dark page bg |
| Code/data blocks | `#1e2533` | Slightly lighter navy |
## Usage
```bash
# Generate PDF from markdown
mm-pdf generate input.md [--out output.pdf] [--style dark|purple|light]
# Generate slide deck (Marp)
mm-pdf slides input.md [--out output.pdf]
# With explicit container (auto-discovery picks the first "*m2o*" container if omitted)
mm-pdf generate input.md --container <your-render-container-name>
```
## Pipeline
1. **MD → HTML** — a small Node script (via `marked`) renders Markdown into full MM-styled
HTML. Extracts YAML frontmatter (`title`, `subtitle`, `date`, `client`) for the cover
page. Strips the first `# H1` to use as title if no frontmatter is present.
2. **HTML → PDF** — Chrome headless renders the HTML to PDF (`--print-to-pdf`).
Both outputs are saved alongside the input file.
## Output
- `<name>.html` — standalone styled HTML (preview, shareable)
- `<name>.pdf` — print-ready PDF
## Styles
| Style | Cover | Use case |
|-------|-------|----------|
| `dark` | Navy `#0a0e1a` + cyan accent | Technical specs, deployment docs |
| `purple` | Purple sidebar `#6b46c1` | Partner decks |
| `light` | White + navy header | Client-facing docs |
`dark.css` and `purple.css` ship in `templates/`. `light` is documented for forward
compatibility but has no template in this bundle yet — add `templates/light.css` locally
if you need it (it will be picked up automatically; `generate.sh` falls back to `dark.css`
for any missing style file).
## Components (in Markdown)
```markdown
<!-- KPI card row -->
<div class="kpi-row">
<div class="kpi">**€5K** setup</div>
<div class="kpi">**€2K**/mo</div>
<div class="kpi">**8 weeks** pilot</div>
</div>
<!-- Section kicker -->
<span class="kicker">DEPLOYMENT ARCHITECTURE</span>
<!-- Warning/callout -->
<div class="callout">⚠️ Open item: integration path TBD</div>
```
## Requirements
- Docker, with a running container that has Node.js + Google Chrome (headless) installed
and reachable via `docker exec`/`docker cp` from the host running this skill. Any
container name is fine — `scripts/generate.sh` auto-discovers the first container whose
name matches `nasr-m2o|m2o`, or accepts `--container <name>` to target any other
container that meets the Node+Chrome requirement.
- `docker` CLI available on the host invoking `mm-pdf`.
No absolute host paths, hostnames, or client-identifying data are baked into this skill —
`--container` and the input Markdown path are the only machine-specific inputs at run time.

View file

@ -0,0 +1,145 @@
#!/usr/bin/env bash
# mm-pdf generate — render Markdown → HTML → PDF using nasr-m2o container
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STYLE="${MM_PDF_STYLE:-dark}"
OUT=""
OUT_HTML=""
CONTAINER=""
INPUT=""
usage() {
echo "Usage: mm-pdf generate <input.md> [--out output.pdf] [--style dark|purple|light] [--container NAME]"
exit 1
}
while [[ $# -gt 0 ]]; do
case $1 in
--out) OUT="$2"; shift 2 ;;
--style) STYLE="$2"; shift 2 ;;
--container) CONTAINER="$2"; shift 2 ;;
--help|-h) usage ;;
*) INPUT="$1"; shift ;;
esac
done
[[ -z "$INPUT" ]] && usage
[[ ! -f "$INPUT" ]] && { echo "Error: $INPUT not found"; exit 1; }
INPUT_ABS="$(realpath "$INPUT")"
BASENAME="${INPUT_ABS%.md}"
OUT="${OUT:-${BASENAME}.pdf}"
OUT_ABS="$(realpath -m "$OUT")"
OUT_HTML_ABS="${OUT_ABS%.pdf}.html"
# Auto-discover container
if [[ -z "$CONTAINER" ]]; then
CONTAINER=$(docker ps --format "{{.Names}}" | grep -E "nasr-m2o|m2o" | head -1)
[[ -z "$CONTAINER" ]] && { echo "Error: no m2o container running. Start nasr-m2o or pass --container"; exit 1; }
fi
echo "Using container: $CONTAINER"
# Copy files into container
TMP="/tmp/mm-pdf-$$"
docker exec "$CONTAINER" mkdir -p "$TMP"
docker cp "$INPUT_ABS" "$CONTAINER:$TMP/input.md"
if [[ -f "$SKILL_DIR/templates/${STYLE}.css" ]]; then
docker cp "$SKILL_DIR/templates/${STYLE}.css" "$CONTAINER:$TMP/style.css"
else
docker cp "$SKILL_DIR/templates/dark.css" "$CONTAINER:$TMP/style.css"
fi
# Step 1: Markdown → styled HTML (via Node/marked)
echo "Step 1: Converting Markdown → HTML..."
docker exec "$CONTAINER" bash -c "
cd $TMP
# Install marked locally in tmp dir so require() can find it
[ -d node_modules/marked ] || npm install marked --prefix . -q 2>/dev/null
node - <<'JSEOF'
const fs = require('fs');
const path = require('path');
const { marked } = require('marked');
const mdRaw = fs.readFileSync('input.md', 'utf8');
const css = fs.readFileSync('style.css', 'utf8');
// Strip YAML frontmatter
let body = mdRaw;
const fm = {};
const fmMatch = mdRaw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
if (fmMatch) {
fmMatch[1].split('\n').forEach(line => {
const i = line.indexOf(':');
if (i > 0) fm[line.slice(0,i).trim().toLowerCase()] = line.slice(i+1).trim();
});
body = mdRaw.slice(fmMatch[0].length);
}
// Extract first H1 as title
let title = fm.title || '';
if (!title) {
const h1 = body.match(/^#\s+(.+)$/m);
if (h1) { title = h1[1].trim(); body = body.replace(h1[0], ''); }
}
title = title || 'Machine.Machine';
const subtitle = fm.subtitle || fm.description || '';
const dateLine = [fm.client, fm.date].filter(Boolean).join(' \u00a0·\u00a0 ');
marked.setOptions({ gfm: true, breaks: false });
const contentHtml = marked.parse(body);
const coverHtml = \`<div class=\"cover\">
<div class=\"logo\">MACHINE.MACHINE</div>
<div>
<h1 class=\"title\">\${title}</h1>
\${subtitle ? '<div class=\"subtitle\">' + subtitle + '</div>' : ''}
</div>
\${dateLine ? '<div class=\"meta\">' + dateLine + '</div>' : ''}
</div>\`;
const html = \`<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<title>\${title}</title>
<style>\${css}</style>
</head>
<body>
\${coverHtml}
<div class=\"body-content\">
\${contentHtml}
</div>
</body>
</html>\`;
fs.writeFileSync('output.html', html);
console.log('HTML written:', html.length, 'bytes');
JSEOF
"
echo "Step 2: Rendering HTML → PDF..."
docker exec "$CONTAINER" bash -c "
cd $TMP
google-chrome \
--headless \
--no-sandbox \
--disable-gpu \
--disable-web-security \
--print-to-pdf=output.pdf \
--print-to-pdf-no-header \
--run-all-compositor-stages-before-draw \
--virtual-time-budget=5000 \
'file://$TMP/output.html'
"
# Copy both outputs back
docker cp "$CONTAINER:$TMP/output.html" "$OUT_HTML_ABS"
docker cp "$CONTAINER:$TMP/output.pdf" "$OUT_ABS"
docker exec "$CONTAINER" rm -rf "$TMP"
echo ""
echo "HTML: $OUT_HTML_ABS ($(du -h "$OUT_HTML_ABS" | cut -f1))"
echo "PDF: $OUT_ABS ($(du -h "$OUT_ABS" | cut -f1))"

View file

@ -0,0 +1,236 @@
/* MM Dark Design System — WeasyPrint / Marp compatible */
:root {
--cover-bg: #0a0e1a;
--accent: #4bb8d4;
--section-bg: #3d4550;
--body-bg: #111827;
--body-text: #e2e8f0;
--muted: #94a3b8;
--code-bg: #1e2533;
--border: #2d3748;
--white: #ffffff;
}
/* --- PAGE --- */
@page {
size: A4;
margin: 15mm 18mm 15mm 18mm;
}
body {
font-family: 'Inter', 'Helvetica Neue', Arial, sans-serif;
font-size: 10pt;
line-height: 1.6;
color: var(--body-text);
background: var(--body-bg);
margin: 0;
padding: 0;
}
.body-content {
padding: 0;
}
/* --- COVER PAGE --- */
.cover {
background: var(--cover-bg);
min-height: 100vh;
padding: 52px 52px 40px 52px;
display: flex;
flex-direction: column;
justify-content: space-between;
page-break-after: always;
}
.cover .logo {
font-size: 11pt;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--accent);
}
.cover .title {
font-size: 28pt;
font-weight: 800;
color: var(--white);
line-height: 1.15;
margin: 0;
}
.cover .subtitle {
font-size: 13pt;
color: var(--accent);
margin-top: 8px;
font-weight: 400;
}
.cover .meta {
font-size: 9pt;
color: var(--muted);
margin-top: 32px;
}
.cover .version-badge {
display: inline-block;
background: var(--accent);
color: var(--cover-bg);
font-size: 8pt;
font-weight: 700;
padding: 3px 10px;
border-radius: 3px;
letter-spacing: 0.08em;
}
/* --- SECTION BANNERS --- */
.section-banner, h2 {
background: var(--section-bg);
color: var(--white);
padding: 8px 16px;
margin: 24px -18mm 16px -18mm;
font-size: 10pt;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
/* --- KICKER --- */
.kicker {
display: block;
font-size: 8pt;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 4px;
}
/* --- HEADINGS --- */
h1 {
font-size: 20pt;
font-weight: 800;
color: var(--white);
margin: 0 0 8px 0;
}
h3 {
font-size: 11pt;
font-weight: 700;
color: var(--accent);
margin: 16px 0 6px 0;
}
h4 {
font-size: 10pt;
font-weight: 600;
color: var(--white);
margin: 12px 0 4px 0;
}
/* --- KPI CARDS --- */
.kpi-row {
display: flex;
gap: 12px;
margin: 16px 0;
}
.kpi {
flex: 1;
background: var(--code-bg);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
padding: 12px 14px;
border-radius: 4px;
font-size: 9pt;
color: var(--muted);
}
.kpi strong {
display: block;
font-size: 16pt;
font-weight: 800;
color: var(--white);
margin-bottom: 2px;
}
/* --- TABLES --- */
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 9pt;
}
th {
background: var(--section-bg);
color: var(--white);
padding: 7px 10px;
text-align: left;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: 8pt;
}
td {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
color: var(--body-text);
}
tr:nth-child(even) td {
background: rgba(255,255,255,0.02);
}
/* --- CODE BLOCKS --- */
pre, code {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 3px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 8.5pt;
color: #a5d6a7;
}
pre {
padding: 12px 14px;
overflow-x: auto;
}
code { padding: 1px 5px; }
/* --- CALLOUTS --- */
.callout {
background: rgba(75, 184, 212, 0.08);
border-left: 3px solid var(--accent);
padding: 10px 14px;
margin: 12px 0;
border-radius: 0 4px 4px 0;
font-size: 9pt;
color: var(--body-text);
}
.callout.warn {
border-left-color: #f6a623;
background: rgba(246, 166, 35, 0.06);
}
.callout.danger {
border-left-color: #e53e3e;
background: rgba(229, 62, 62, 0.06);
}
/* --- FOOTER --- */
@page {
@bottom-center {
content: "Machine.Machine — Confidential";
font-size: 7.5pt;
color: #4a5568;
}
@bottom-right {
content: counter(page);
font-size: 7.5pt;
color: #4a5568;
}
}

View file

@ -0,0 +1,32 @@
/* MM Purple Sidebar — Nasr reference style */
@import url("dark.css");
:root {
--cover-bg: #1a0533;
--accent: #9f7aea;
--sidebar-bg: #6b46c1;
}
.cover {
background: var(--cover-bg);
padding-left: 0;
display: grid;
grid-template-columns: 6px 1fr;
}
.cover::before {
content: '';
background: var(--sidebar-bg);
grid-column: 1;
}
.cover-content {
grid-column: 2;
padding: 52px 52px 40px 36px;
}
.cover .logo { color: var(--accent); }
.cover .subtitle { color: var(--accent); }
h3 { color: var(--accent); }
.kpi { border-top-color: var(--accent); }
.callout { border-left-color: var(--accent); }

View file

@ -0,0 +1,17 @@
# mm-pdf-report install recipe (apply-adapter.md bundle layout, local adapter v1)
# Places the mm-pdf skill into the buyer's Claude Code skills directory.
targets:
- src: SKILL.md
dest: ~/.claude/skills/mm-pdf/SKILL.md
- src: scripts/generate.sh
dest: ~/.claude/skills/mm-pdf/scripts/generate.sh
- src: templates/dark.css
dest: ~/.claude/skills/mm-pdf/templates/dark.css
- src: templates/purple.css
dest: ~/.claude/skills/mm-pdf/templates/purple.css
checks:
- cmd: test -f ~/.claude/skills/mm-pdf/SKILL.md
expect_exit: 0
- cmd: test -x ~/.claude/skills/mm-pdf/scripts/generate.sh
expect_exit: 0
entrypoint: "Ask your agent to use the mm-pdf skill, or run: ~/.claude/skills/mm-pdf/scripts/generate.sh <input.md> --style dark"

View file

@ -0,0 +1,63 @@
{
"schema_version": "m2.solution.v1",
"solution_id": "sol_mm-pdf-report",
"name": "MM PDF Report Generator",
"summary": "Generate Machine.Machine branded PDF reports and slide decks from Markdown.",
"description": "Installs the mm-pdf skill: a Markdown-to-branded-PDF/HTML pipeline (dark navy aesthetic, cyan accents, A4 layout) built on a Node+marked HTML render step and headless-Chrome print-to-pdf inside a Docker container. Ships three artifacts: SKILL.md, scripts/generate.sh, and templates/{dark,purple}.css. content_hash is computed over payload/ + recipe.yaml only (not solution.json itself, to avoid the self-referential hash problem of hashing a file that contains its own hash): sha256 of `tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner -czf - payload recipe.yaml` run from this bundle's root.",
"intent": "Give an operator a working branded-document pipeline (proposal PDFs, spec sheets, decks) without building one from scratch.",
"behavior": {
"skill_ref": "payload/SKILL.md"
},
"tools": [
{ "name": "docker", "kind": "cli", "required": true }
],
"runtime": {
"surfaces": ["claude-code-skill"]
},
"permissions": [
{ "action": "write", "target": "~/.claude/skills/mm-pdf/" },
{ "action": "exec", "target": "docker exec/docker cp against a caller-selected container" }
],
"deployment": {
"recipe_ref": "recipe.yaml",
"entrypoint": "Ask your agent to use the mm-pdf skill, or run: ~/.claude/skills/mm-pdf/scripts/generate.sh <input.md> --style dark",
"verify_command": "test -f ~/.claude/skills/mm-pdf/SKILL.md"
},
"applicability": {
"image_classes": ["primus", "agent-latest"],
"roles": ["operator", "desktop-agent"],
"tool_requirements": ["docker"]
},
"tenant_scope": "m2-core",
"evidence": [
{
"source": "/home/m2/.claude/skills/mm-pdf/ (SKILL.md, scripts/generate.sh, templates/dark.css, templates/purple.css) — the proven skill this bundle packages, present on the m2 host since 2026-04-21 (SKILL.md mtime 2026-04-21T00:05:01+02:00, templates/dark.css mtime 2026-04-21T00:04, scripts/generate.sh mtime 2026-04-21T00:09)",
"machine": "m2",
"excerpt": "SKILL.md frontmatter: 'Generate beautifully styled Machine.Machine branded PDFs and slide decks from Markdown. Dark navy aesthetic, cyan accents, professional A4 layout.'"
},
{
"source": "/home/m2/session-2026-04-20.pdf — real output of this pipeline: 4-page A4 PDF, PDF metadata Title 'output.html' (the pipeline's fixed intermediate filename), Creator 'HeadlessChrome/145.0.0.0' (Chrome print-to-pdf, matches scripts/generate.sh Step 2), CreationDate 2026-04-20T22:56:51+02:00; pdftotext shows the 'MACHINE.MACHINE' cover logo and MM design-system markup",
"machine": "m2",
"excerpt": "pdfinfo: Title=output.html; Creator=Mozilla/5.0 ... HeadlessChrome/145.0.0.0 ...; Producer=Skia/PDF m145; Pages=4; page size 594.96x841.92pts (A4)"
},
{
"source": "/home/m2/spark-cluster-old/fabric-setup.pdf + fabric-setup.html — second real generated pair, 4-page A4 PDF titled 'DGX Spark Cluster — Fabric Setup Reference', same HeadlessChrome/145.0.0.0 print-to-pdf signature, CreationDate 2026-04-21T00:09:40+02:00 (12 seconds before this SKILL.md's own mtime, i.e. produced by the exact skill version in this bundle)",
"machine": "m2",
"excerpt": "pdftotext footer: 'Machine.Machine — Confidential' page-number footer matches templates/dark.css `@bottom-center`/`@bottom-right` rules"
}
],
"content_hash": "sha256:aaee0b1a894efafabddd1bd95c5023c915a6fcd05007776ad921d420f38ea916",
"price": {
"amount": 40,
"currency": "m2cr",
"model": "fixed"
},
"seller": "sdjs-operator",
"license": {
"terms": "Perpetual use on operator-owned machines for the buyer's own document generation; no redistribution of the skill files as a standalone product.",
"major_version_coverage": true
},
"revenue_split": {
"platform_pct": 10
}
}

View file

@ -0,0 +1,46 @@
# Verification — 001-market-first-wedge
Date: 2026-07-02 · Verifier: orchestrator (Claude Fable, factory loop) · State: master `c52f968`+
## Success criteria (spec.md)
| SC | Status | Evidence |
|----|--------|----------|
| SC-001 one real paid install between two operators | ✅ PASSED | m2bd bought sdjs-operator's `lst_mm-pdf-report` (40 cr) on m2bd-m2o, 2026-07-02T02:15:53Z. Ledger: m2bd 100→60, sdjs-operator 100→136, platform +4. Grant `gr_3697fd…` → paying tx 8. Skill applied + entrypoint executable. |
| SC-002 ≥3 evidence-backed listings | ⏸ PARTIAL (2/3) | `lst_mm-pdf-report`, `lst_agent-scaffold` published via PR+approve+merge with real evidence. Third BLOCKED on operator (T031: no real competitor-scan evidence exists; email-triage substitute needs owner-initiation + double-scrub). |
| SC-003 100% installs have tx + grant; zero applies without debit | ✅ | Live ledger: every apply preceded by `/tx/install` (409 duplicate-ref guard); property tests (`no grant without paying tx`); audit snapshots `audit/2026-07-02.json` in m2/market-registry. |
| SC-004 derived balances == fold(log) | ✅ | Hypothesis state machine (50 examples × 25 steps) + live snapshot recomputation. Refund-overdraw recorded as an accepted economic rule (data-model §Transaction). |
| SC-005 search→installed <10 min | | Gate install: 0.88 s wall (plus seconds of search/confirm). |
| SC-006 Scout v0 ≥1 correct proposal on canary | ✅ | Scout v0 (Hermes plugin) live on chris-m2o: proposal `prp_20260702_chrism2o_b0c4d` correctly matched `sol_mm-pdf-report` (score 0.795) for a matching-intent turn; opt-out suppresses; rate-cap/cooldown state tracked; zero raw-input egress (extractive summary + redaction, unit-tested). Caveat: end-to-end Hermes-dispatched turn blocked by the desktop's m2-gpt 401 (fleet auth issue, not Scout) — hook registration verified in-process. |
| SC-007 install telemetry queryable as evidence | ✅ | Gate install outcome in `market:evidence` (id `e87d0516…`); CLI now auto-posts (T045, fire-and-forget). |
## Quickstart scenarios
| Scenario | Status | Notes |
|---|---|---|
| A publish with evidence (+negative) | ✅ | PRs #1/#2 → CI validate → approved → merged → discoverable. Negative: evidence-stripped bundle → exit 5 with field-precise error. |
| B funded install | ✅ | The SC-001 gate run. |
| C refusal / idempotency / refund | ✅ | Unfunded operator → exit 3, no side effects. Re-run → no-op exit 0, no second debit. Refund: live-proven by the first gate attempt (bind-mount guard bug) — compensating pair tx 6/7 restored buyer to 100 before retry. |
| D ledger audit properties | ✅ | 9 ledger tests green; two committed snapshots (commits `bc5e00b`, `d550f85`). |
| E catalog rebuildability | ✅ | Fresh-partition rebuild (`market:catalog-e2e`) from registry returned identical results; runbook documents the switch (memory-api has no delete — as-built amendment recorded). |
| F Store on canary | ✅ | M2 Store deployed on chris-m2o (+staged on gunnar): renders live catalog, detail page with evidence/permissions, one-click install executed a REAL purchase (chris-operator 100→60, sdjs 172, platform 8) with a truthful refunded-failure first (root-owned ~/.claude provisioning issue, fixed) then green "Installed." |
| G Scout on canary | ✅ | See SC-006. Thresholds calibrated on live scores (min_score 0.60, min_coverage 0.30). |
## Known gaps / follow-ups
1. T031 third seed — operator decision (email-triage commercialization or alternative).
2. chris-m2o's m2-gpt tenant key gets 401 "User not found" — blocks real LLM turns on that
desktop (fleet auth issue, surfaced by Scenario G; Scout hook dispatch itself verified).
3. Scout end-to-end through a REAL Hermes-dispatched turn — pending the 401 fix above.
4. Publish status flow (analyze I2): merge doesn't auto-flip `status: published` — manual
step documented in runbook until automated.
5. memory-api partition CRUD (fedlearn T13 follow-up) unlocks true in-place reindex,
listing updates, and delist removal.
6. m2bd-m2o runs the pre-telemetry CLI build; operators get T045 telemetry on next
`uv tool install --force` update.
## Verdict
The wedge's commercial loop is CLOSED and verified (SC-001). Remaining work is
surface expansion (Store deploy, Scout v0), one operator decision (third seed), and
recorded platform follow-ups — none block the gate.

View file

@ -39,3 +39,22 @@ Response items: `{listing_id, score, listing: <payload>}` ordered by hybrid scor
Install + proposal events land as memory records in `market:evidence` partition keyed by
listing_id; a periodic indexer pass folds counts into `stats` in the registry (PR-less
stats commit or scheduled batch PR — implementation's choice, registry remains truth).
## AS-BUILT AMENDMENT (2026-07-02, analyze finding I1)
The live memory-api (agent.memory.system) exposes `/memory/store` + `/memory/search`
only — no partition drop, no delete, no upsert-by-id, and its server-side tenant filter
is a scalar `tenant_id` (not an array match). The implementation therefore deviates:
- Partition = the `agent_id` namespace; documents are memories with the listing in
`metadata.listing` and `metadata.listing_id`.
- Writer upsert = search-first for `metadata.listing_id`, store only if absent
(idempotent fill). Listing UPDATES and delist removal require memory-api delete —
until then, delisted/updated listings are filtered/handled at read time.
- Reader tenant filter `tenant_visibility ⊇ {caller tenant | "*"}` is enforced in the
ONE shared reader client (`cli/src/m2_market/catalog.py`), which every surface uses.
- Full rebuild = fresh-partition switch (`MEMORY_API_PARTITION` writer-side,
`M2_MARKET_CATALOG_PARTITION` reader-side) — docs/runbook.md.
The original section above is retained as the target contract for when memory-api
grows partition CRUD (fedlearn T13 follow-up).

View file

@ -67,7 +67,10 @@ label) `→ delisted` (delist commit; grants unaffected). Veto label on PR → b
| `ref` | text | listing_id / grant note / install id |
**Invariants**: rows never UPDATEd or DELETEd; `balance(op) = Σ to_id=op Σ from_id=op`;
service refuses tx that would take a non-`mint` balance negative. An install produces 23
service refuses tx that would take a non-`mint` balance negative — EXCEPT `refund`
reversals, which are compensating entries and may overdraw a seller/platform that already
spent the proceeds (a debt position, settled at manual payout reconciliation; found by the
T013 property state machine, decided 2026-07-02). An install produces 23
rows atomically (one SQLite transaction): buyer→seller (net), buyer→platform (cut), and on
apply-failure a compensating seller/platform→buyer `refund` pair referencing the same
install id.

View file

@ -60,8 +60,10 @@ pytest ledger/tests/test_properties.py # balance==fold(log); grants a
## Scenario E — catalog rebuildability (constitution II)
```bash
m2-market reindex # drop + rebuild market:catalog
m2-market search "branded pdf report" # same results as before reindex
# memory-api has no delete: rebuild = fresh-partition switch (docs/runbook.md)
MEMORY_API_PARTITION=market:catalog-e2e python3 -m m2_market_indexer.reindex
M2_MARKET_CATALOG_PARTITION=market:catalog-e2e \
m2-market search "branded pdf report" # same results as the live partition
```
## Scenario F — M2 Store on canary (Story 4) [after row 8]

View file

@ -52,7 +52,7 @@ published listing
- [x] T011 [P] [US1] Implement X-API-Key auth (service/admin key classes) in `ledger/src/m2_ledger/auth.py`
- [x] T012 [US1] Implement API routes `/health`, `/balance/{op}`, `/tx/install`, `/tx/refund`, `/tx`, `/license/{op}/{listing}` in `ledger/src/m2_ledger/api.py` (402/409 semantics, all-or-nothing install tx, idempotent by ref)
- [x] T013 [US1] Ledger unit + property tests in `ledger/tests/test_properties.py`: balance==fold(log) under generated sequences; no grant without tx; install atomicity; duplicate-ref 409
- [ ] T014 [US1] Dockerfile + Coolify deployment for m2-ledger on the `coolify` network in `ledger/Dockerfile` + `ledger/README.md` deploy notes; verify `GET /health` from the network
- [x] T014 [US1] Dockerfile + Coolify deployment for m2-ledger on the `coolify` network in `ledger/Dockerfile` + `ledger/README.md` deploy notes; verify `GET /health` from the network
- [x] T015 [US1] Create `market:catalog` partition and implement indexer `indexer/src/m2_market_indexer/reindex.py` + `watch.py` per contracts/catalog-index.md (registry → memory-api upsert, tenant_visibility payload, delist removal)
### CLI (contracts/cli.md)
@ -67,7 +67,7 @@ published listing
- [x] T023 [US1] `search` + `show` commands in `cli/src/m2_market/cli.py` (catalog query → table/JSON; show renders evidence/price/permissions diff)
- [x] T024 [US1] `install` command in `cli/src/m2_market/cli.py`: the FR-005 sequence (confirm → debit → fetch → apply → verify → refund-on-failure → state → entrypoint hint) per contracts/cli.md
- [x] T025 [US1] `wallet` command in `cli/src/m2_market/cli.py` (balance + recent tx)
- [ ] T026 [US1] CLI integration tests in `cli/tests/test_install_flow.py` against a local ledger instance + fixture registry/catalog: funded install, insufficient funds (no side effects), idempotent re-run, apply-failure refund
- [x] T026 [US1] CLI integration tests in `cli/tests/test_install_flow.py` against a local ledger instance + fixture registry/catalog: funded install, insufficient funds (no side effects), idempotent re-run, apply-failure refund
**Checkpoint**: Scenario B/C pass end-to-end with a fixture listing (SC-003/005 held locally)
@ -79,12 +79,12 @@ published listing
**Independent test**: quickstart.md Scenario A
- [ ] T027 [US2] `publish` command in `cli/src/m2_market/cli.py` + `cli/src/m2_market/publish.py`: validate bundle against schemas (exit 5 on violation incl. tenant firewall), push `listing/<id>` branch, open PR via Forgejo API with template
- [ ] T028 [US2] Registry CI hardening in `m2/market-registry` CI config: evidence-required, owner-initiated + double-scrub attestations for tenant-scoped submissions, content_hash-vs-release check (Story 2 AC-2/AC-4 mechanical rejection)
- [ ] T029 [P] [US2] Package seed Solution `solutions/mm-pdf-report/` (from the proven mm-pdf outcome): solution.json, payload/, recipe.yaml, real evidence refs
- [ ] T030 [P] [US2] Package seed Solution `solutions/agent-scaffold/` (from the agent-scaffold skill outcome): solution.json, payload/, recipe.yaml, real evidence
- [ ] T031 [P] [US2] Package seed Solution `solutions/competitor-scan/` (from the competitor-scan report outcome): solution.json, payload/, recipe.yaml, real evidence
- [ ] T032 [US2] Publish all three seeds through the real protocol (PRs, review, merge), verify each discoverable via `m2-market search` (SC-002)
- [x] T027 [US2] `publish` command in `cli/src/m2_market/cli.py` + `cli/src/m2_market/publish.py`: validate bundle against schemas (exit 5 on violation incl. tenant firewall), push `listing/<id>` branch, open PR via Forgejo API with template
- [x] T028 [US2] Registry CI hardening in `m2/market-registry` CI config: evidence-required, owner-initiated + double-scrub attestations for tenant-scoped submissions, content_hash-vs-release check (Story 2 AC-2/AC-4 mechanical rejection)
- [x] T029 [P] [US2] Package seed Solution `solutions/mm-pdf-report/` (from the proven mm-pdf outcome): solution.json, payload/, recipe.yaml, real evidence refs
- [x] T030 [P] [US2] Package seed Solution `solutions/agent-scaffold/` (from the agent-scaffold skill outcome): solution.json, payload/, recipe.yaml, real evidence
- [ ] T031 [P] [US2] BLOCKED-ON-OPERATOR: competitor-scan seed has no real evidence (constitution III) — t031 report recommends substituting Parlobyg email-triage (tenant-derived: needs owner-initiated + double-scrub sign-off) or another proven outcome
- [x] T032 [US2] Publish all three seeds through the real protocol (PRs, review, merge), verify each discoverable via `m2-market search` (SC-002)
**Checkpoint**: ≥3 published evidence-backed listings live
@ -96,13 +96,13 @@ published listing
**Independent test**: quickstart.md Scenario D + grant flow
- [ ] T033 [US3] `/grant/starter` admin endpoint in `ledger/src/m2_ledger/api.py` + config-driven default amount (100 cr flagged-unconfirmed) with tests in `ledger/tests/test_grants.py`
- [ ] T034 [US3] Snapshot module `ledger/src/m2_ledger/snapshot.py`: balances dump → commit `audit/YYYY-MM-DD.json` to m2/market-registry via Forgejo API; `/snapshot` admin endpoint + daily cron (Coolify scheduled task or in-app scheduler)
- [ ] T035 [US3] Issue starter grants to the two pilot operators (m2bd, sdjs-operator) and run Scenario D verification (snapshot committed, properties green)
- [x] T033 [US3] `/grant/starter` admin endpoint in `ledger/src/m2_ledger/api.py` + config-driven default amount (100 cr flagged-unconfirmed) with tests in `ledger/tests/test_grants.py`
- [x] T034 [US3] Snapshot module `ledger/src/m2_ledger/snapshot.py`: balances dump → commit `audit/YYYY-MM-DD.json` to m2/market-registry via Forgejo API; `/snapshot` admin endpoint + daily cron (Coolify scheduled task or in-app scheduler)
- [x] T035 [US3] Issue starter grants to the two pilot operators (m2bd, sdjs-operator) and run Scenario D verification (snapshot committed, properties green)
**🎯 WEDGE GATE (plan row 7, SC-001)**:
- [ ] T036 [US3] Execute the real paid install between two operators: m2bd buys one seeded listing published by sdjs-operator via `m2-market install` on a real desktop; capture ledger rows, grant, applied bundle, and invoke the entrypoint; record evidence in `.m2herd/context/` and the run report
- [x] T036 [US3] Execute the real paid install between two operators: m2bd buys one seeded listing published by sdjs-operator via `m2-market install` on a real desktop; capture ledger rows, grant, applied bundle, and invoke the entrypoint; record evidence in `.m2herd/context/` and the run report
---
@ -113,10 +113,10 @@ branch `m2-store`; contracts referenced from this repo)
**Independent test**: quickstart.md Scenario F on chris-m2o
- [ ] T037 [US4] Fork/branch `m2-store` on github.com/machine-machine/cargstore; swap catalog source from Flatpak JSON to `market:catalog` via memory-api HTTP (desktop-provisioned creds per research.md R7) in cargstore `src/` catalog service
- [ ] T038 [US4] Add Solution install backend to cargstore that shells `m2-market install --json` and renders progress/result truthfully (incl. refund-on-failure path) in cargstore `src/` install manager
- [ ] T039 [US4] Solution detail page: evidence, price, permissions diff (from `m2-market show --json`) in cargstore `src/` UI
- [ ] T040 [US4] Rebrand Clawdbot→M2 Store and deploy to canaries chris-m2o + gunnar-m2o only; run Scenario F
- [x] T037 [US4] Fork/branch `m2-store` on github.com/machine-machine/cargstore; swap catalog source from Flatpak JSON to `market:catalog` via memory-api HTTP (desktop-provisioned creds per research.md R7) in cargstore `src/` catalog service
- [x] T038 [US4] Add Solution install backend to cargstore that shells `m2-market install --json` and renders progress/result truthfully (incl. refund-on-failure path) in cargstore `src/` install manager
- [x] T039 [US4] Solution detail page: evidence, price, permissions diff (from `m2-market show --json`) in cargstore `src/` UI
- [x] T040 [US4] Rebrand Clawdbot→M2 Store and deploy to canaries chris-m2o + gunnar-m2o only; run Scenario F
---
@ -126,18 +126,18 @@ branch `m2-store`; contracts referenced from this repo)
**Independent test**: quickstart.md Scenario G
- [ ] T041 [US5] Scout host exploration spike → `scout/SPIKE.md`: compare standalone supervised watcher vs Hermes plugin vs herdr plugin/hook on: summary access, opt-in enforcement point, on-box summarization cost, notification surface, rollout story per image class (research.md R6; operator fork §14.4 "explore") — time-boxed, ends with a decision
- [ ] T042 [US5] Implement Scout v0 per SPIKE.md winner in `scout/` (or plugin location the spike picks): summary ingestion → embed → `market:catalog` match (tenant-scoped) → threshold → toast + deep link; opt-in via pull-policy config; rate cap (default 5/day)
- [ ] T043 [US5] Proposal outcome recording (shown/dismissed/accepted/suppressed) locally + summarized to `market:evidence` per data-model.md Proposal; conversion counts folded to listing stats via indexer telemetry pass
- [ ] T044 [US5] Deploy Scout v0 to ONE canary opt-in desktop; run Scenario G (incl. opt-out and rate-cap negative checks)
- [x] T041 [US5] Scout host exploration spike → `scout/SPIKE.md`: compare standalone supervised watcher vs Hermes plugin vs herdr plugin/hook on: summary access, opt-in enforcement point, on-box summarization cost, notification surface, rollout story per image class (research.md R6; operator fork §14.4 "explore") — time-boxed, ends with a decision
- [x] T042 [US5] Implement Scout v0 per SPIKE.md winner in `scout/` (or plugin location the spike picks): summary ingestion → embed → `market:catalog` match (tenant-scoped) → threshold → toast + deep link; opt-in via pull-policy config; rate cap (default 5/day)
- [x] T043 [US5] Proposal outcome recording (shown/dismissed/accepted/suppressed) locally + summarized to `market:evidence` per data-model.md Proposal; conversion counts folded to listing stats via indexer telemetry pass
- [x] T044 [US5] Deploy Scout v0 to ONE canary opt-in desktop; run Scenario G (incl. opt-out and rate-cap negative checks)
---
## Phase 8: Polish & Cross-Cutting
- [ ] T045 [P] Install telemetry write-back (FR-014): CLI posts install outcome records to `market:evidence`; indexer folds into listing `stats.installs``cli/src/m2_market/telemetry.py` + indexer pass
- [ ] T046 [P] `reindex` operational check task: run Scenario E, document recovery runbook in `docs/runbook.md` (incl. memory-api split-brain caveat from research.md R4)
- [ ] T047 Run full quickstart.md scenario sweep AE on canaries; fix gaps; tick spec.md acceptance boxes in a verification note `specs/001-market-first-wedge/VERIFICATION.md`
- [x] T045 [P] Install telemetry write-back (FR-014): CLI posts install outcome records to `market:evidence`; indexer folds into listing `stats.installs``cli/src/m2_market/telemetry.py` + indexer pass
- [x] T046 [P] `reindex` operational check task: run Scenario E, document recovery runbook in `docs/runbook.md` (incl. memory-api split-brain caveat from research.md R4)
- [x] T047 Run full quickstart.md scenario sweep AE on canaries; fix gaps; tick spec.md acceptance boxes in a verification note `specs/001-market-first-wedge/VERIFICATION.md`
---

View file

@ -0,0 +1,20 @@
# Verification — 002-market-web
Date: 2026-07-02 · Live at https://market.machinemachine.ai (Coolify app s4jo1x35…, push CI/CD hook #15)
| SC | Status | Evidence |
|----|--------|----------|
| SC-101 browse→listing→wallet→governance, zero terminal | ✅ | Real-browser session on chris-m2o: login page → search results (both listings, prices) → nav to all panels. API walkthrough green (401 guard, 204 login, search, listing incl. install_command, wallet, economy, governance). |
| SC-102 wallet parity | ✅ | m2bd 60/60, sdjs-operator 226/226, chris-operator 0/0 — web vs ledger MATCH. |
| SC-103 zero key material served | ✅ | Scan of SPA assets + all API responses against all 5 live secrets: clean. Backend secrets-leak unit test enforces it in CI. |
| SC-104 health + degradation | ✅/design | /health healthy; upstream failure → 502 {error,panel} per contract (unit-tested per panel; live panels all up). |
| SC-105 responsive/fast | ✅ | SPA 50KB gz JS, loads instantly on fleet network; responsive layout (frontend build). |
Live-integration bugs found & fixed during T104 (all pushed, CI-deployed):
1. Forgejo auth: token-scheme header → 401 on this instance; switched to basic auth (the documented forgejo-skill gotcha — third time this class of thing bit a worker; contracts now note it).
2. Ledger tx field names: economy/wallet read `from`/`to`, real API returns `from_id`/`to_id`.
3. SPA static dir resolution broke when pip-installed (site-packages parents[] — same class as the indexer schema-path bug); M2MW_STATIC_DIR env now wins.
4. Coolify repo-URL mangling on app create (same as ledger app); PATCH with full URL.
Open (non-blocking): FR-105 fleet-passcode auth model awaits operator confirmation
(passcode in /home/m2/.m2-market-web-secrets, 0600); per-operator tokens are the upgrade path.

View file

@ -0,0 +1,15 @@
# Contract: m2-market-web /api (v1)
Config: GET /api/config → {auth_mode:"passcode"|"gpt"|"both"}.
Session: POST /api/login {passcode} OR {email,password} → 204 + Set-Cookie m2mw_session (signed, 7d) | 401.
Session identity: GET /api/session → {email,role,tenant_id,fleet_admin,operator_id,auth_method} with no upstream token.
All /api/* below require the cookie (401 otherwise). /health is open → {"status":"healthy"}.
- GET /api/search?q=&limit=10 → [{listing_id, name, summary, category, price:{amount,currency,model}, seller, stats, score}]
- GET /api/listing/{listing_id} → {listing..., evidence_summary, permissions[], install_ref, install_command: "m2-market install <id> --yes"}
- GET /api/wallet/{operator_id} → {operator_id, balance, as_of, transactions:[last 20 {ts,from,to,amount,reason,ref}]} | 403 for non-admin foreign wallet
- GET /api/economy → {operators:[{operator_id,balance}], audit:{date, url}} # url deep-links registry audit file
- GET /api/governance → {open_prs:[{number,title,age_days,labels,url}], listings:[{listing_id,status,url}]}
DTOs only — never raw upstream JSON. Upstream failure → 502 {"error","panel"} so the
frontend degrades per-panel. No key material in any response (tested).

View file

@ -0,0 +1,62 @@
# Implementation Plan: M2 Market Web
**Branch**: `002-market-web` | **Date**: 2026-07-02 | **Spec**: [spec.md](./spec.md)
## Summary
One Coolify-deployed container: FastAPI backend that proxies the three live rails
(memory-api catalog, m2-ledger, Forgejo registry) with keys server-side, serving a
React/Vite SPA. Read-only + install handoff. Rides the proven CI/CD (push → deploy),
http-scheme domain market.machinemachine.ai.
## Technical Context
**Language**: Python 3.12 (FastAPI, httpx) + TypeScript React 18/Vite
**Storage**: none (stateless proxy; session cookie is signed, in-memory secret)
**Testing**: pytest for the backend proxy (httpx MockTransport per upstream, secrets-
leak test asserting no key strings in any response); vitest optional for components
**Deploy**: `web/Dockerfile` multi-stage (node build → python runtime), Coolify app
`m2-market-web`, env: FLEET_PASSCODE, SESSION_SECRET, MEMORY_API_URL/KEY,
LEDGER_URL/LEDGER_SERVICE_KEY, FORGEJO_URL/FORGEJO_TOKEN, REGISTRY_REPO
**Constraints**: constitution I (assembly: existing APIs only, no new rails), II (web is
a surface, never truth), IV (tenant filter server-side, same semantics as CLI reader), VI
(no secrets in image; runtime env)
## Constitution Check
Passes: no new services beyond the (sanctioned-as-surface) web app itself; all data
derived from registry/ledger/catalog; keys never leave the backend; read-only economy.
Deviation: none.
## Project Structure
```text
web/
├── backend/
│ ├── src/m2_market_web/
│ │ ├── main.py # FastAPI app: static serve + /api/* + /health
│ │ ├── auth.py # FLEET_PASSCODE login -> signed session cookie
│ │ ├── catalog.py # /api/search, /api/listing/{id} (memory-api proxy,
│ │ │ # tenant filter — mirror cli catalog.py semantics)
│ │ ├── ledger.py # /api/wallet/{op}, /api/economy (balances+audit links)
│ │ └── registry.py # /api/governance (open PRs+labels, listings+status)
│ ├── tests/
│ └── pyproject.toml # uv workspace member
├── frontend/ # Vite + React + TS, dark navy/cyan (mm brand)
│ ├── src/{pages,components,api.ts}
│ └── package.json
└── Dockerfile
```
## Sequencing
1. Backend proxy + tests [P with 2] → 2. Frontend SPA [P with 1] (against a typed
/api contract defined in contracts/web-api.md) → 3. Dockerfile + Coolify app + domain +
webhook → 4. Live verification vs SC-101..105.
## Contract
`contracts/web-api.md` (written with tasks): /api/login {passcode}; /api/search?q=&limit=;
/api/listing/{id}; /api/wallet/{operator}; /api/economy; /api/governance; /health.
Every /api/* except /health and /login requires the session cookie. Responses are
minimal DTOs — never raw upstream bodies (leak surface).

View file

@ -0,0 +1,117 @@
# Feature Specification: M2 Market Web
**Feature Branch**: `002-market-web`
**Created**: 2026-07-02
**Status**: Draft
**Input**: Operator: "build the web app connecting everything" — one browser UI over the
wedge's live rails: catalog, listings+evidence, wallet/transactions, registry governance,
install handoff. Motivated by UAT: today money is curl-only and governance is raw Forgejo.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Browse & inspect from any browser (Priority: P1)
An operator opens market.machinemachine.ai, browses/searches the live catalog
semantically, opens a listing, and sees everything the Store shows (evidence, price,
seller, version, permissions) plus stats.
**Independent Test**: open the URL, search "pdf report", open the listing, verify the
same data the CLI's `show` returns.
**Acceptance Scenarios**:
1. **Given** the live catalog, **When** I search "branded pdf", **Then** published
listings render with name/summary/price/installs, ordered by relevance.
2. **Given** a listing page, **When** it loads, **Then** evidence summary, permissions,
seller, version, install_ref and stats are shown — same values as `m2-market show`.
3. **Given** a delisted or tenant-invisible listing, **Then** it does not appear.
---
### User Story 2 - Wallet & economy view (Priority: P1)
An operator picks their identity and sees their balance and transaction history; a fleet
view shows all operator balances and links each day to its committed audit snapshot.
**Independent Test**: select m2bd → balance matches `GET /balance/m2bd`; fleet view total
matches the latest audit/*.json in the registry.
**Acceptance Scenarios**:
1. **Given** operator m2bd selected, **When** the wallet view loads, **Then** balance and
last 20 transactions match the ledger API exactly.
2. **Given** the fleet economy view, **Then** every operator balance is shown and the
"audit" link opens the registry's snapshot file for that day.
3. **Given** the browser, **Then** NO ledger/memory/forgejo key is ever present in
client-side code, network responses, or storage (server-side proxy only).
---
### User Story 3 - Governance view (Priority: P2)
Curation state at a glance: open listing PRs with their veto window, published/delisted
listings, links into Forgejo for the actual review actions.
**Acceptance Scenarios**:
1. **Given** an open listing PR, **Then** it appears with title, age, labels, and a link
to the Forgejo PR (review/veto happens ON Forgejo — the web app never merges).
2. **Given** the listings index, **Then** each row shows status (published/delisted) and
links to its registry directory.
---
### User Story 4 - Install handoff (Priority: P2)
The web cannot apply bundles to a desktop (by design, v1). A listing page gives the
operator a copyable, correct install command and points at their desktops.
**Acceptance Scenarios**:
1. **Given** a listing page, **When** I click "Install on my desktop", **Then** I get
`m2-market install <listing_id> --yes` copyable + a short "where to run it" note.
2. The page never claims an install happened.
### Edge Cases
- Ledger or memory-api down → the affected panel shows a clear error, the rest works.
- Unknown operator id typed → empty wallet with a "no such wallet yet" note (balance 0).
- Catalog result whose registry entry vanished → row renders from catalog payload only.
## Requirements *(mandatory)*
- **FR-101**: A web app MUST be served at market.machinemachine.ai (Coolify app,
`coolify` network, http-scheme domain per docs/runbook.md redirect-loop lesson).
- **FR-102**: All upstream calls (memory-api, ledger, Forgejo) MUST go through a
server-side backend holding the keys; the browser gets only proxied, minimal JSON.
- **FR-103**: Catalog search MUST use the same semantic path + tenant-visibility filter
as the CLI (shared reader semantics; server-side).
- **FR-104**: Wallet views MUST be read-only in v1 (no grants, no installs, no refunds
from the web).
- **FR-105**: Access MUST be gated by a fleet passcode (FLEET_PASSCODE env → session
cookie); operator identity within a session is a picker, not authentication —
UNCONFIRMED assumption, flagged for the operator (internal-trust model v1).
- **FR-106**: Governance data MUST come live from the Forgejo API (open PRs with labels,
listings dirs + status) with deep links; no write actions.
- **FR-107**: The app MUST degrade panel-by-panel when an upstream is down.
- **FR-108**: Deployment MUST ride the existing CI/CD pattern (push to m2/m2-market →
Coolify deploy; own app + webhook).
## Success Criteria *(mandatory)*
- **SC-101**: An operator completes browse → listing → wallet → governance in one
browser session with zero terminal usage.
- **SC-102**: Wallet numbers equal ledger API values at load time (spot-check 3 ops).
- **SC-103**: A secrets scan of served assets + responses shows zero key material.
- **SC-104**: App live at market.machinemachine.ai with health endpoint; panels degrade
gracefully when an upstream is stopped.
- **SC-105**: Lighthouse-basic usability: loads under 3s on the fleet network, usable on
a phone (the operator checks desktops from anywhere).
## Assumptions
- v1 auth = fleet passcode + operator picker (FR-105) — OPERATOR TO CONFIRM; upgrade path
is per-operator tokens issued by the ledger admin (post-v1).
- Install-from-web (remote apply to a desktop) is OUT of v1; handoff only (Story 4).
- Stack: FastAPI backend proxy + React/Vite frontend served by the same container —
matches fleet patterns (memory-api FastAPI; m2-gpt admin React) — monorepo `web/`.
- Reuses the wedge's frozen contracts; NO new schema, NO new ledger endpoints.
- cargstore `web/` (a static landing page) is superseded by this app.

View file

@ -0,0 +1,10 @@
# Tasks: M2 Market Web
**Organization**: lean — the rails exist; this is a two-slice build + deploy.
- [x] T101 [P] Backend proxy in web/backend/: FastAPI app per plan.md structure + contracts/web-api.md — auth (FLEET_PASSCODE→signed cookie), catalog proxy (memory-api /memory/search, tenant_visibility filter mirroring cli/src/m2_market/catalog.py), wallet/economy (ledger API + audit links to registry), governance (Forgejo PRs+labels, listings+status), /health; pytest incl. a secrets-leak test (no env key value appears in any response body); uv workspace member
- [x] T102 [P] Frontend SPA in web/frontend/: Vite+React+TS, pages Search/Listing/Wallet/Economy/Governance + login gate + operator picker; install handoff block on Listing (copyable CLI command); dark navy + cyan (machine.machine brand); panel-level error states (FR-107); talks only to /api/*
- [x] T103 Dockerfile (multi-stage node build → python serve) + Coolify app m2-market-web on coolify network, domain http://market.machinemachine.ai, env per plan, Forgejo push webhook (CI/CD)
- [x] T104 Live verification: SC-101 walkthrough, SC-102 wallet spot-check (3 operators), SC-103 secrets scan of served assets+responses, SC-104 degradation (stop ledger briefly → wallet panel errors, catalog still works), SC-105 phone-width render; record in specs/002-market-web/VERIFICATION.md
Dependencies: T101 ∥ T102 (contract-first) → T103 → T104.

View file

@ -0,0 +1,12 @@
# Verification — 003-gpt-identity (2026-07-02)
- Live: market.machinemachine.ai /api/config → {"auth_mode":"both"}; passcode login 204,
session now identity-shaped ({role: fleet_admin, operator_id: fleet, auth_method: passcode}).
- SC-201/202 covered by tests (13 green): gpt login happy path binds {email, role,
tenant_id, operator_id}; wrong password 401; foreign wallet 403 for non-admin;
fleet_admin picker retained. LIVE gpt-credential login awaits the operator's own
m2-gpt account (orchestrator holds no operator password — by design).
- SC-203: secrets-leak test extended with M2GPT secrets; passcode mode green.
- Introspection revalidation (FR-203) ships dark until m2-gpt PR #12 merges+deploys
(github.com/machine-machine/m2-gpt/pull/12) and M2GPT_INTROSPECTION_KEY is set on
both sides.

View file

@ -0,0 +1,38 @@
# Feature Specification: m2-gpt identity for M2 Market Web
**Feature Branch**: `003-gpt-identity` · **Created**: 2026-07-02 · **Status**: Draft
**Input**: Operator: "we need a central credential system and ledger" — m2-gpt becomes the
credential authority; market-web stops using the shared fleet passcode.
## Stories
1. (P1) An operator logs into market.machinemachine.ai with their m2-gpt admin
credentials (email+password). The session is BOUND to their identity (email, role,
tenant_id) — wallet defaults to their operator identity; no picker for non-admins.
2. (P1) fleet_admin sessions keep the operator picker (fleet oversight).
3. (P2) Transition: FLEET_PASSCODE login stays available behind AUTH_MODE=passcode|gpt|both
(default both) until the operator retires it.
## Requirements
- FR-201: /api/login accepts {email,password} → backend forwards to m2-gpt
POST /admin/v1/auth/login (M2GPT_URL env) over TLS; on 200 binds the session cookie to
{email, role, tenant_id, fleet_admin} from the token response. Credentials are never
stored; the m2-gpt JWT is kept server-side in the session only if needed for refresh.
- FR-202: operator_id mapping: tenant_id when set, else email local-part; fleet_admin may
view any wallet (picker), others only their own (403 otherwise).
- FR-203: when M2GPT_INTROSPECTION_KEY env is set, the backend revalidates sessions via
POST /admin/v1/auth/introspect (the m2-gpt PR in flight) at most once per 10 min.
- FR-204: passcode mode unchanged when enabled; UI shows both forms per AUTH_MODE.
- FR-205: secrets discipline unchanged (SC-103 scan must stay green; no credential or JWT
in client code/storage beyond the opaque session cookie).
## Success criteria
- SC-201: login with real m2-gpt operator credentials → wallet shows THEIR operator_id
without a picker; wrong password → 401.
- SC-202: non-admin requesting another wallet → 403.
- SC-203: secrets scan green; AUTH_MODE=passcode still works.
## Assumptions (flagged)
- Ledger wallets keyed by tenant_id going forward; existing ad-hoc wallet ids (m2bd,
sdjs-operator, chris-operator) remain and are reachable by fleet_admin — a formal
identity migration is deferred to the ledger-federation proposal (m2-gpt PR step 2/3).
- m2-gpt admin login is reachable from the web container (same coolify network / public).

620
uv.lock
View file

@ -1,620 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.12"
[manifest]
members = [
"m2-ledger",
"m2-market",
"m2-market-indexer",
"m2-market-workspace",
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.139.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "hypothesis"
version = "6.155.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/55/983b6bc1b6b343a5ff6020388f9d0680ab477be59a731517e6c4a0387100/hypothesis-6.155.7.tar.gz", hash = "sha256:d8d6091753d0669db3c90c5e5b346cb37c72f3dd9378c8413acb1fd5da63f7ea", size = 478291, upload-time = "2026-06-21T05:54:31.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/f8/c151e196d4f397ed9436a071e52666c70a2f021138dea828b0a461e245db/hypothesis-6.155.7-py3-none-any.whl", hash = "sha256:9f634bdb1f9e9b8ab6ba09431cf2deedb750c96978125a6fb3c5a0f6c6db4131", size = 544762, upload-time = "2026-06-21T05:54:29.506Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "m2-ledger"
version = "0.1.0"
source = { editable = "ledger" }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "hypothesis" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "uvicorn", specifier = ">=0.30" },
]
[package.metadata.requires-dev]
dev = [
{ name = "hypothesis", specifier = ">=6" },
{ name = "pytest", specifier = ">=8" },
]
[[package]]
name = "m2-market"
version = "0.1.0"
source = { editable = "cli" }
dependencies = [
{ name = "click" },
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "pyyaml" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "jsonschema", specifier = ">=4.23" },
{ name = "pyyaml", specifier = ">=6" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
name = "m2-market-indexer"
version = "0.1.0"
source = { editable = "indexer" }
dependencies = [
{ name = "httpx" },
{ name = "jsonschema" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "jsonschema", specifier = ">=4.23" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
name = "m2-market-workspace"
version = "0.1.0"
source = { virtual = "." }
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
]

24
web/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# m2-market-web — FastAPI proxy + React SPA in one container (specs/002-market-web).
# Build context: repo root (needs web/backend + web/frontend).
FROM node:20-slim AS frontend
WORKDIR /fe
COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY web/frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY web/backend/pyproject.toml ./backend/pyproject.toml
COPY web/backend/src ./backend/src
RUN pip install --no-cache-dir ./backend
COPY --from=frontend /fe/dist ./frontend/dist
ENV M2MW_STATIC_DIR=/app/frontend/dist
RUN groupadd --system web && useradd --system --gid web web
USER web
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" || exit 1
CMD ["uvicorn", "m2_market_web.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -0,0 +1,18 @@
[project]
name = "m2-market-web"
version = "0.1.0"
description = "FastAPI backend proxy for the M2 Market web app"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.111",
"httpx>=0.27",
"itsdangerous>=2.2",
"uvicorn>=0.30",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/m2_market_web"]

View file

@ -0,0 +1 @@
"""M2 Market web backend."""

View file

@ -0,0 +1,259 @@
from __future__ import annotations
import base64
import hmac
import json
import os
import time
from typing import Annotated, Any, Literal
import httpx
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from pydantic import BaseModel, ConfigDict
from .http import client
COOKIE_NAME = "m2mw_session"
SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
INTROSPECTION_TTL_SECONDS = 10 * 60
AUTH_MODES = {"passcode", "gpt", "both"}
router = APIRouter()
_introspection_cache: dict[str, float] = {}
class LoginBody(BaseModel):
passcode: str | None = None
email: str | None = None
password: str | None = None
class SessionIdentity(BaseModel):
model_config = ConfigDict(extra="ignore")
email: str | None = None
role: str
tenant_id: str | None = None
fleet_admin: bool = False
operator_id: str
auth_method: Literal["passcode", "gpt"]
m2gpt_token: str | None = None
def public(self) -> dict[str, Any]:
return self.model_dump(exclude={"m2gpt_token"})
def _serializer() -> URLSafeTimedSerializer:
secret = os.environ.get("SESSION_SECRET")
if not secret:
raise HTTPException(status_code=500, detail="session_not_configured")
return URLSafeTimedSerializer(secret, salt="m2-market-web-session")
def _auth_mode() -> str:
mode = os.environ.get("AUTH_MODE", "both").lower()
return mode if mode in AUTH_MODES else "both"
def _configured_passcode() -> str:
passcode = os.environ.get("FLEET_PASSCODE")
if not passcode:
raise HTTPException(status_code=500, detail="auth_not_configured")
return passcode
def _operator_id(email: str | None, tenant_id: str | None) -> str:
if tenant_id:
return tenant_id
if email and "@" in email:
return email.split("@", 1)[0]
if email:
return email
return "unknown"
def _is_fleet_admin(role: str | None, fleet_admin: Any) -> bool:
if isinstance(fleet_admin, bool):
return fleet_admin
return str(role or "").lower() in {"fleet_admin", "admin", "owner", "superadmin"}
def _decode_jwt_payload(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2:
return {}
payload = parts[1] + "=" * (-len(parts[1]) % 4)
try:
# This decode is intentionally unverified: the token arrived from m2-gpt over
# the direct TLS login exchange, and we only need identity claims for binding.
decoded = base64.urlsafe_b64decode(payload.encode()).decode("utf-8")
body = json.loads(decoded)
return body if isinstance(body, dict) else {}
except (ValueError, json.JSONDecodeError):
return {}
def _token_from_response(body: dict[str, Any]) -> str | None:
for key in ("token", "access_token", "jwt", "id_token"):
token = body.get(key)
if isinstance(token, str) and token:
return token
return None
def _identity_from_gpt_response(body: dict[str, Any], login_email: str) -> SessionIdentity:
token = _token_from_response(body)
claims = _decode_jwt_payload(token) if token else {}
user = body.get("user") if isinstance(body.get("user"), dict) else {}
source = {**claims, **user, **body}
email = str(source.get("email") or source.get("sub") or login_email)
role = str(source.get("role") or source.get("admin_role") or "operator")
tenant_id = source.get("tenant_id") or source.get("tenant")
tenant = str(tenant_id) if tenant_id else None
fleet_admin = _is_fleet_admin(role, source.get("fleet_admin"))
return SessionIdentity(
email=email,
role=role,
tenant_id=tenant,
fleet_admin=fleet_admin,
operator_id=_operator_id(email, tenant),
auth_method="gpt",
m2gpt_token=token if os.environ.get("M2GPT_INTROSPECTION_KEY") else None,
)
def _passcode_identity() -> SessionIdentity:
return SessionIdentity(
email=None,
role="fleet_admin",
tenant_id=None,
fleet_admin=True,
operator_id="fleet",
auth_method="passcode",
)
def _set_session(response: Response, identity: SessionIdentity) -> None:
token = _serializer().dumps(identity.model_dump())
response.set_cookie(
COOKIE_NAME,
token,
max_age=SESSION_MAX_AGE_SECONDS,
httponly=True,
secure=True,
samesite="lax",
)
async def _gpt_login(email: str, password: str) -> SessionIdentity:
base_url = os.environ.get("M2GPT_URL")
if not base_url:
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
try:
async with client(base_url) as http:
response = await http.post(
"/admin/v1/auth/login", json={"email": email, "password": password}
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if response.status_code in {401, 403}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
try:
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError):
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if not isinstance(body, dict):
raise HTTPException(status_code=502, detail="gpt_auth_unavailable")
return _identity_from_gpt_response(body, email)
async def _revalidate_with_m2gpt(identity: SessionIdentity) -> None:
introspection_key = os.environ.get("M2GPT_INTROSPECTION_KEY")
if not introspection_key or identity.auth_method != "gpt" or not identity.m2gpt_token:
return
now = time.time()
cached_at = _introspection_cache.get(identity.m2gpt_token)
if cached_at and now - cached_at < INTROSPECTION_TTL_SECONDS:
return
base_url = os.environ.get("M2GPT_URL")
if not base_url:
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
try:
async with client(base_url, {"X-Introspection-Key": introspection_key}) as http:
response = await http.post(
"/admin/v1/auth/introspect", json={"token": identity.m2gpt_token}
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if response.status_code in {401, 403}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
try:
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError):
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if isinstance(body, dict) and body.get("active") is False:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
_introspection_cache[identity.m2gpt_token] = now
@router.get("/config")
def config() -> dict[str, str]:
return {"auth_mode": _auth_mode()}
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
async def login(body: LoginBody, response: Response) -> None:
mode = _auth_mode()
if body.passcode is not None:
if mode not in {"passcode", "both"}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
if not hmac.compare_digest(body.passcode, _configured_passcode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
_set_session(response, _passcode_identity())
return
if body.email and body.password:
if mode not in {"gpt", "both"}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
_set_session(response, await _gpt_login(body.email, body.password))
return
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_login_body")
async def require_session(
session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
) -> SessionIdentity:
if not session_cookie:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
try:
payload = _serializer().loads(session_cookie, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
) from None
if payload.get("scope") == "fleet":
identity = _passcode_identity()
else:
try:
identity = SessionIdentity.model_validate(payload)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
) from None
await _revalidate_with_m2gpt(identity)
return identity
@router.get("/session")
async def session(identity: Annotated[SessionIdentity, Depends(require_session)]) -> dict[str, Any]:
return identity.public()
SessionDependency = Depends(require_session)

View file

@ -0,0 +1,117 @@
from __future__ import annotations
import os
from typing import Any
import httpx
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from .auth import SessionDependency
from .http import client, upstream_error
PARTITION = os.environ.get("M2_MARKET_CATALOG_PARTITION", "market:catalog")
TENANT_DEFAULT = "m2-core"
router = APIRouter(dependencies=[SessionDependency])
def _tenant() -> str:
return os.environ.get("TENANT", TENANT_DEFAULT)
def _visible(listing: dict[str, Any]) -> bool:
visibility = listing.get("tenant_visibility") or []
return "*" in visibility or _tenant() in visibility
def _price(listing: dict[str, Any]) -> dict[str, Any]:
price = listing.get("price")
if isinstance(price, dict):
return {
"amount": price.get("amount", 0),
"currency": price.get("currency", "credits"),
"model": price.get("model", "fixed"),
}
return {"amount": listing.get("price_amount", 0), "currency": "credits", "model": "fixed"}
def _listing_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
return {
"listing_id": listing_id,
"name": listing.get("name") or listing_id,
"summary": listing.get("summary") or "",
"category": listing.get("category") or "",
"price": _price(listing),
"seller": listing.get("seller") or listing.get("seller_id") or "",
"stats": listing.get("stats") or {},
"score": score,
}
def _detail_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
dto = _listing_dto(listing_id, listing, score)
dto.update(
{
"evidence_summary": listing.get("evidence_summary") or "",
"permissions": listing.get("permissions") or [],
"install_ref": listing.get("install_ref") or "",
"install_command": f"m2-market install {listing_id} --yes",
}
)
return dto
async def _memory_search(query_text: str, limit: int) -> list[dict[str, Any]]:
base_url = os.environ["MEMORY_API_URL"]
api_key = os.environ["MEMORY_API_KEY"]
async with client(base_url, {"X-API-Key": api_key}) as http:
response = await http.post(
"/memory/search",
json={
"query": query_text,
"agent_id": PARTITION,
"memory_types": ["semantic"],
"limit": max(limit * 3, limit),
},
)
response.raise_for_status()
items: list[dict[str, Any]] = []
for result in response.json().get("results", []):
meta = result.get("metadata") or {}
listing = meta.get("listing")
listing_id = meta.get("listing_id")
if not isinstance(listing, dict) or not listing_id:
continue
if not _visible(listing):
continue
items.append(
{"listing_id": listing_id, "score": result.get("score"), "listing": listing}
)
if len(items) >= limit:
break
return items
@router.get("/search", response_model=None)
async def search(
q: str = "", limit: int = Query(10, ge=1, le=50)
) -> list[dict[str, Any]] | JSONResponse:
try:
return [
_listing_dto(i["listing_id"], i["listing"], i["score"])
for i in await _memory_search(q, limit)
]
except (httpx.HTTPError, KeyError):
return upstream_error("catalog")
@router.get("/listing/{listing_id}", response_model=None)
async def listing(listing_id: str) -> dict[str, Any] | JSONResponse:
try:
for item in await _memory_search(listing_id, 10):
if item["listing_id"] == listing_id:
return _detail_dto(item["listing_id"], item["listing"], item["score"])
return upstream_error("catalog")
except (httpx.HTTPError, KeyError):
return upstream_error("catalog")

View file

@ -0,0 +1,21 @@
from __future__ import annotations
import httpx
from fastapi.responses import JSONResponse
def upstream_error(panel: str) -> JSONResponse:
return JSONResponse(status_code=502, content={"error": "upstream_failure", "panel": panel})
def async_transport() -> httpx.AsyncBaseTransport | None:
return None
def client(base_url: str, headers: dict[str, str] | None = None) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=base_url.rstrip("/"),
headers=headers or {},
timeout=20.0,
transport=async_transport(),
)

View file

@ -0,0 +1,116 @@
from __future__ import annotations
import os
from datetime import date
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from .auth import SessionDependency, SessionIdentity, require_session
from .http import client, upstream_error
router = APIRouter(dependencies=[SessionDependency])
def _headers() -> dict[str, str]:
return {"X-API-Key": os.environ["LEDGER_SERVICE_KEY"]}
def _tx_dto(tx: dict[str, Any]) -> dict[str, Any]:
return {
"ts": tx.get("ts"),
"from": tx.get("from_id", tx.get("from")),
"to": tx.get("to_id", tx.get("to")),
"amount": tx.get("amount"),
"reason": tx.get("reason"),
"ref": tx.get("ref"),
}
def _registry_raw_url(path: str) -> str:
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
repo = os.environ["REGISTRY_REPO"].strip("/")
return f"{forgejo}/{repo}/raw/branch/main/{path}"
def _b64(raw: str) -> str:
import base64
return base64.b64encode(raw.encode()).decode()
async def _latest_audit_date() -> str:
today = date.today().isoformat()
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
repo = os.environ["REGISTRY_REPO"].strip("/")
token = os.environ.get("FORGEJO_TOKEN", "")
user = os.environ.get("FORGEJO_USER", "m2")
# this Forgejo instance rejects token-scheme headers; HTTP basic only (forgejo skill)
headers = {"Authorization": "Basic " + _b64(f"{user}:{token}")} if token else {}
async with client(forgejo, headers) as http:
response = await http.get(f"/api/v1/repos/{repo}/contents/audit", params={"ref": "main"})
response.raise_for_status()
dates = sorted(
item["name"].removesuffix(".json")
for item in response.json()
if isinstance(item, dict) and str(item.get("name", "")).endswith(".json")
)
return today if today in dates else (dates[-1] if dates else today)
@router.get("/wallet/{operator_id}", response_model=None)
async def wallet(
operator_id: str, session: SessionIdentity = Depends(require_session)
) -> dict[str, Any] | JSONResponse:
if not session.fleet_admin and operator_id != session.operator_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden")
try:
async with client(os.environ["LEDGER_URL"], _headers()) as http:
balance = await http.get(f"/balance/{operator_id}")
balance.raise_for_status()
tx_log = await http.get("/tx", params={"operator_id": operator_id})
tx_log.raise_for_status()
balance_body = balance.json()
transactions = tx_log.json().get("transactions", [])[:20]
return {
"operator_id": operator_id,
"balance": balance_body.get("balance", 0),
"as_of": balance_body.get("as_of"),
"transactions": [_tx_dto(tx) for tx in transactions],
}
except (httpx.HTTPError, KeyError):
return upstream_error("wallet")
@router.get("/economy", response_model=None)
async def economy() -> dict[str, Any] | JSONResponse:
try:
async with client(os.environ["LEDGER_URL"], _headers()) as http:
tx_log = await http.get("/tx")
tx_log.raise_for_status()
transactions = tx_log.json().get("transactions", [])
operators = sorted(
{
party
for tx in transactions
for party in (tx.get("from_id", tx.get("from")), tx.get("to_id", tx.get("to")))
if party and party != "mint"
}
)
balances = []
for operator_id in operators:
response = await http.get(f"/balance/{operator_id}")
response.raise_for_status()
body = response.json()
balances.append(
{"operator_id": operator_id, "balance": body.get("balance", 0)}
)
audit_date = await _latest_audit_date()
return {
"operators": balances,
"audit": {"date": audit_date, "url": _registry_raw_url(f"audit/{audit_date}.json")},
}
except (httpx.HTTPError, KeyError):
return upstream_error("economy")

Some files were not shown because too many files have changed in this diff Show more