Compare commits
No commits in common. "e244050ff99bfcd93f9fd1357a67babc21788189" and "ab01f39842949c23ea4690658b4fe03c66086236" have entirely different histories.
e244050ff9
...
ab01f39842
8 changed files with 7 additions and 868 deletions
|
|
@ -1,68 +0,0 @@
|
||||||
# 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`.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
#!/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."
|
|
||||||
|
|
@ -1,537 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
name: m2-solution-scout
|
|
||||||
version: "0.1"
|
|
||||||
description: Solution Scout intent watch
|
|
||||||
hooks:
|
|
||||||
- on_session_start
|
|
||||||
- post_llm_call
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -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 | ✅ | 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-006 Scout v0 ≥1 correct proposal on canary | ⏳ OPEN | Spike done (T041, DECISION: Hermes plugin); v0 implementation T042–T044 pending. |
|
||||||
| 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 | ✅ | 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." |
|
| 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. |
|
||||||
| G Scout on canary | ✅ | See SC-006. Thresholds calibrated on live scores (min_score 0.60, min_coverage 0.30). |
|
| G Scout on canary | ⏳ OPEN | After T042–T044. |
|
||||||
|
|
||||||
## Known gaps / follow-ups
|
## Known gaps / follow-ups
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
- [x] T040 [US4] Rebrand Clawdbot→M2 Store and deploy to canaries chris-m2o + gunnar-m2o only; run Scenario F
|
- [ ] 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
|
||||||
- [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)
|
- [ ] 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
|
- [ ] 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)
|
- [ ] T044 [US5] Deploy Scout v0 to ONE canary opt-in desktop; run Scenario G (incl. opt-out and rate-cap negative checks)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue