Compare commits

...

3 commits

Author SHA1 Message Date
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
8 changed files with 868 additions and 7 deletions

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`.

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

@ -11,7 +11,7 @@ Date: 2026-07-02 · Verifier: orchestrator (Claude Fable, factory loop) · State
| 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-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-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-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 | ⏳ OPEN | Spike done (T041, DECISION: Hermes plugin); v0 implementation T042T044 pending. | | 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). | | 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 ## Quickstart scenarios
@ -23,8 +23,8 @@ Date: 2026-07-02 · Verifier: orchestrator (Claude Fable, factory loop) · State
| 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. | | 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`). | | 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). | | 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 | ⏳ OPEN | M2 Store BUILT (cargstore `m2-store` branch: catalog source, install backend shelling the CLI, detail page, rebrand; AppImage builds). Canary deploy = T040. | | 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 | ⏳ OPEN | After T042T044. | | G Scout on canary | ✅ | See SC-006. Thresholds calibrated on live scores (min_score 0.60, min_coverage 0.30). |
## Known gaps / follow-ups ## Known gaps / follow-ups

View file

@ -116,7 +116,7 @@ branch `m2-store`; contracts referenced from this repo)
- [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] 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] 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] 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] T040 [US4] Rebrand Clawdbot→M2 Store and deploy to canaries chris-m2o + gunnar-m2o only; run Scenario F
--- ---
@ -127,9 +127,9 @@ branch `m2-store`; contracts referenced from this repo)
**Independent test**: quickstart.md Scenario G **Independent test**: quickstart.md Scenario G
- [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] 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) - [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)
- [ ] 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] 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] T044 [US5] Deploy Scout v0 to ONE canary opt-in desktop; run Scenario G (incl. opt-out and rate-cap negative checks)
--- ---