Skill: 5rbdmak7f-alt/memory-guardian-agent (version 1.0.0)

--- name: memory-guardian description: > Agent workspace memory lifecycle management via 10 MCP tools + batch maintenance. Manages MEMORY.md, memory/ directory, meta.json, quality gate, Bayesian decay, case lifecycle, and compaction. Use when: (1) Installing or configuring memory-guardian, (2) Running scheduled or on-demand memory maintenance, (3) Diagnosing memory bloat, quality anomalies, or case invalidation, (4) Writing, querying, or archiving memories, (5) Reviewing or retiring judgment cases, (6) Checking quality gate state or L3 confirmations. Triggers on: memory_status, memory_decay, memory_ingest, memory_compact, quality_check, case_query, case_review, run_batch, memory_sync, meta.json, MEMORY.md, memory-guardian. --- # memory-guardian Workspace memory lifecycle system. Dual-layer Bayesian decay + four-state quality gate + case lifecycle + compaction. ## Design Principles 1. **Check status before any write** — `memory_status` first 2. **Dry-run before apply** — preview destructive operations 3. **Default behavior > toggles** — workspace defaults from `MG_WORKSPACE` 4. **Write ordering > content correctness** — sequence matters 5. **Observable but not brittle** — signal degradation → WARNING, not crash 6. **Single source of truth** — all defaults in `mg_schema/meta_defaults.py` ## MCP Tools (10) workspace defaults from `MG_WORKSPACE` env var; only non-default params listed. **Query** - `memory_status()` — System overview (memory count / gate state / case summary / references integrity) - `memory_query(type="active", min_score=0.3)` — Search memories (keyword/memory_type filter) **Write** - `memory_ingest(content="...", importance="auto", tags=[])` — Create new memory - `memory_decay(lambda=0.01, dry_run=false)` — Run five-track Bayesian decay **Audit** - `quality_check(layer="all")` — Quality gate (retire_rate / similar_case_signal / stale_cases) - `case_query(filter="frozen")` — Query cases (active/frozen/retired/stale/ignored) - `case_review(case_id, action="retire", origin_type="agent_initiated")` — Case operations (active/frozen/retired/unfreeze/ignore) **Batch** - `run_batch(skip_compact=true, dry_run=false, timeout=300)` — Full maintenance (includes sync + signal merge) - `memory_sync(dry_run=true)` — Sync file changes → meta.json (auto-run in run_batch) - `memory_compact(dry_run=true, aggressive=false)` — Compact MEMORY.md ## Workflows ### New Installation 1. `memory_status` → confirm `references.complete: true` 2. If false, create missing files per the missing list, re-verify 3. Create cron task (see [signal-loop.md](references/signal-loop.md) for cron template) 4. Manually trigger once to verify ### Daily Maintenance (cron) `run_batch(skip_compact=true)` runs automatically. Includes: - memory_sync (incremental file scan) - Signal merge (access_log + cron inference) - Decay + quality gate check - Compact triggers only when MEMORY.md > 15KB ### Diagnostics **D1: Memory bloat** → `memory_compact(dry_run=true)` → apply if needed → see [compaction.md](references/compaction.md) **D2: Quality anomaly** → `quality_check(layer="all")` → see [error_recovery.md](references/error_recovery.md) **D3: Case invalidation** → `case_query(filter="stale")` → `case_review(action="retire"|"active"|"unfreeze")` → see [case-management.md](references/case-management.md) ### Signal Loop (v0.4.6) Dual-layer access signals feed the decay engine: - **Layer 1** (weight 1.0): `access_log.jsonl` — agent appends after `memory_get` - **Layer 2** (weight 0.5): cron keyword inference from daily notes - Health check auto-degrades to Layer 2 if access_log stale > 24h Agent must append to `access_log.jsonl` after each `memory_get` call. See [signal-loop.md](references/signal-loop.md) for integration code. ## References Load on demand per scenario: - [signal-loop.md](references/signal-loop.md) — Signal loop setup, AGENTS.md integration code, cron template, config fields - [triggers.md](references/triggers.md) — Trigger/anti-trigger rules - [parameters.md](references/parameters.md) — Decay params, β scar, PID gains, TTL - [compaction.md](references/compaction.md) — D1 diagnosis and compaction strategy - [error_recovery.md](references/error_recovery.md) — D2 diagnosis, anomaly states, self-healing - [case-management.md](references/case-management.md) — D3 diagnosis, case audit, L3 review - [advanced-tools.md](references/advanced-tools.md) — Quiet degradation, topic lock, PID adaptive ## CLI Fallback When MCP unavailable, CLI path relative to skill dir: ```bash python3 scripts/memory_guardian.py <command> [--workspace <path>] ``` Commands: `status`, `ingest`, `bootstrap`, `snapshot`, `run`, `violations`, `migrate`

path: .gitignore
content: # OS / editor noise
.DS_Store
Thumbs.db
.idea/
*.iml

# Legacy JetBrains paths kept for compatibility
/shelf/
/workspace.xml
/httpRequests/
/queries/
/dataSources/
/dataSources.local.xml

# Codex local workspace temp dirs
.codex_tmp/
.codex_manual_tmp/
.codex_test_tmp/
.tmp_workspaces/
tests/.tmp_workspaces/
AGENTS.md

# Runtime data (user-instance specific, not code)
memory/

# Python / test / runtime artifacts
__pycache__/
*.py[cod]
.pytest_cache/
pytest-cache-files-*/
.memory-guardian/

# Docs
docs/rfc/
docs/superpowers/


path: CHANGELOG.md
content: # Changelog

## v0.4.6 - 2026-04-15
- **Signal loop (Phase 1)**: Dual-layer access signal collection for the decay engine.
  - Layer 1 (real-time, low noise): `access_log.jsonl` — agent appends entries after `memory_get`, weight 1.0 (configurable).
  - Layer 2 (periodic inference, noisy): cron keyword matching against memory tags in daily notes, weight 0.5 (configurable).
  - Merge formula: `effective_access = log_count × w1 + infer_count × w2`.
  - Signal health check: monitors `access_log.jsonl` mtime, auto-degrades to Layer 2 only with WARNING when stale > threshold.
  - Dual-stage threshold: 2h during deployment period (first 7 days), 24h stable.
  - Access log auto-truncation: keeps last 30 days of entries.
  - `merge_signals()` integrates into `run_batch` start for seamless signal injection.
- **MCP integration**: `append_access_log` and `merge_signals` exposed via signal_loop module.
- **Phase 1 completion**: Signal loop provides real access data to decay engine, closing the "observable but not brittle" design goal.

### Code Quality
- Fixed `import json` placement in `scripts/mg_schema/__init__.py` (was after function definitions, moved to top).
- Resolved version inconsistency: `SCHEMA_VERSION` bumped from 0.4.5 → 0.4.6 to match `PRODUCT_VERSION`.
- Fixed variable shadowing in `scripts/signal_loop.py`: inner loop `for i` (bigram) shadowed outer loop `for i` (day range), renamed to `for bi`.
- Fixed circular import risk in `scripts/memory_sync.py`: replaced `from memory_guardian import workspace_path` with `from mg_utils import workspace_path`.
- Moved `workspace_path()` and `DEFAULT_WORKSPACE` from `memory_guardian.py` to `mg_utils.py` (single source of truth).
- Rewrote `SKILL.md` following skill-creator spec: removed version history, moved signal loop details to `references/signal-loop.md`, reduced from 300 → ~90 lines.

### Verification
- Full test suite: 600 tests all green.

## v0.4.5 - 2026-04-06
- **6-layer on-demand loading architecture**: storage / index / routing / signal / decay / migration — all implemented in one release.
- **Deep code review + 38 fixes** (17 critical / 16 medium / 5 optimization) across 35 source files / 13,703 lines.

### Security & Concurrency
- Fixed file lock TOCTOU: removed `os.unlink` in unlock, lock file now persists (prevents concurrent lock acquisition on new inode).
- Added file lock to `mutate_meta()`: entire load→mutate→save under single lock.
- Added file lock to `telemetry.record_module_run()`: prevents concurrent stat loss.
- Fixed violations log rotation TOCTOU: moved `load_events` inside lock context.
- Security layer high-severity rules now enforced in quality_gate path (was only checking `critical`, now `critical` + `high`).
- CRITICAL state now triggers L3 confirmation for destructive ops (delete/modify).
- Path traversal protection in MCP `resolve_workspace()` via `realpath` + `..` check.
- MCP protocol: handle `notifications/initialized` (silent ignore) and `shutdown` (exit cleanly).

### Data Integrity
- Fixed version constant downgrade: `DEFAULT_VERSION` 0.4.2→0.4.5 (normalize_meta was downgrading on-disk version).
- Fixed sync double-write: removed redundant tail `load_meta→save_meta` that overwrote IngestService writes.
- Ingest atomicity: cleanup written file if meta update fails.
- `case_rollback.create_version_entry()`: unconditional `failure_signals` reset (nested fields were leaking old values).
- `transition_engine.anomaly_count`: now incremented on anomaly detection, reset on recovery (was read-only → anomaly mode never triggered).
- `case_invalidate`: reset `consecutive_low_confidence` on freeze.
- `case_grow.merge`: `failure_signals` snapshot now uses `deepcopy`.

### Bug Fixes
- `quality_gate.enqueue_write()`: fixed `NameError` — `l2_cfg` was never loaded (guaranteed crash on any CRITICAL queue operation).
- `migrate_to_v042.save_meta()`: removed custom implementation (tmp unbound on mkstemp failure), replaced with `mg_utils.save_meta()` wrapper.
- `migrate_to_v042`: content truncation 500→2000 chars.
- `migrate_to_v042`: try/finally cleanup for temporary fields (`_path`, `_workspace`).
- `migrate_retag.step_verify`: each check now uses independent error list (shared list caused `if not errors` logic error).
- `compute_provenance_confidence`: added [0, 1] clamp.
- `generate_memory_id`: MD5 prefix 8→12 chars (48-bit, collision probability reduced from ~1/65K to ~1/28M).
- `pid_adaptive`: D-term now individually clamped to `[-OUTPUT_LIMIT, OUTPUT_LIMIT]`.
- `l3_confirm`: unified timestamp format with `datetime.now(CST)`.
- `memory_dedup`: skip non-active/observing memories (idempotent).
- `case_grow`: added 14 English negation pairs to conflict detection.

### Optimizations
- `normalize_meta`: `read_only=True` path skips deepcopy (significant for large meta.json).
- Removed duplicate `_merge_dict_defaults` (identical to `_deep_merge_defaults`).
- MCP stdout redirect: refactored to `@contextmanager`.
- `run_batch`: timeout protection (checks elapsed time before each step).
- `_fix_meta_types`: errors now logged to stderr instead of silently swallowed.
- `memory_diff`: snapshot cleanup (keeps latest 50).
- `case_review`: documented why some actions bypass script layer.

### New Architecture Components
- `scripts/memory_router.py`: tag-based routing with inverted index, content relevance scoring, routing log.
- `scripts/mg_app/ingest_service.py`: orchestrated ingest with `IngestService`, event logging, quality gate integration.
- `scripts/mg_repo/meta_json_repository.py`: `MetaJsonRepository` with `mutate_meta` (atomic write with hash detection).
- `scripts/mg_schema/`: centralized schema defaults, normalization, deep merge.
- `scripts/mg_state/`: transition engine, quality gate rules.
- `scripts/mg_events/telemetry.py`: module run telemetry with rotation.
- `scripts/migrate_retag.py`: tag-based directory migration with verification.
- Full test suite: 581 tests all green.

## v0.4.4 - 2026-04-04
- Added `scripts/memory_sync.py`: file → meta.json auto-sync engine. Scans MEMORY.md and daily notes for new/updated content, filters by action keywords, dedup checks (threshold=0.85), and ingests via IngestService. 30-minute cooldown to avoid redundant processing. Records `last_sync_at` in meta.json.
- Integrated sync into `cmd_run`: sync now runs as Step 0 before decay in the full maintenance cycle.
- Added `memory_sync` MCP tool (10th tool) to `mcp_server.py`: supports `dry_run` mode, integrated into `run_batch` before decay.
- Updated SKILL.md: added sync tool reference, scenario 4 (file sync), auto-sync note in scenario 2, cron setup guide, updated triggers.
- New test suite: `tests/test_memory_sync.py` — 14 tests covering empty workspace, no changes, new content, dedup skip, keyword filtering, dry-run, timestamp tracking, importance assignment, and combined sources.
- Full test suite: 1131 tests all green (1092 existing + 14 new + 25 test_all incremental).
- Updated README.md: 10 tools, 1131 tests, sync documentation, updated project structure.
- Updated cron job (b42af9ab): payload changed from CLI command to MCP tool invocation (memory_status + run_batch).

## v0.4.3 - 2026-04-04
- Added MCP Server (`mcp_server.py`) providing 9 structured tools (memory_status, memory_query, memory_decay, memory_ingest, memory_compact, quality_check, case_query, case_review, run_batch) with stdio transport and CLI fallback.
- Rewrote SKILL.md following OpenClaw AgentSkills spec: added YAML frontmatter with comprehensive trigger description, diagnostic paths D1/D2/D3, tool quick reference, and per-scenario references/ loading guide.
- Added 6 reference files under `references/` for progressive disclosure: triggers.md, parameters.md, compaction.md, error_recovery.md, case-management.md, advanced-tools.md.
- Passed OpenClaw `package_skill.py` structural validation (frontmatter, naming, file organization, no symlinks).
- Script layer: 19 scripts, zero changes from v0.4.2 code baseline.
- Test suite: 1092 tests all green (532 existing + 560 new across 16 test classes).

### Verification
- `python -m unittest discover -s tests -p "test_*.py" -v`
- `package_skill.py` → `[OK] Skill is valid!`
- neuro stress test: three paths (new user / D2 CRITICAL / degradation), P0+P1 all passed

## v0.4.2 - 2026-03-30
- Hardened the unified CLI bridge in `scripts/memory_guardian.py` with shared argument helpers, deferred result emission, and tighter child-script forwarding for violations, security, quality-gate, case-grow, migration, and invalidation commands.
- Fixed real CLI contract bugs: `migrate --no-backup` now forwards correctly, `migrate-042` rejects `--apply` + `--dry-run`, `security risk` preserves the child default listing mode, and `case-grow record-trigger` now forwards the case id.
- Reduced unnecessary write amplification in `scripts/case_invalidate.py` by batching `save_meta()` calls, and added targeted regression coverage for CLI bridging, ingest compatibility, and invalidation save behavior.
- Cleaned repository-local generated artifacts and tightened ignore rules for temporary workspace directories used by tests and local tooling.

### Verification
- `python -m pytest tests -q`
- `python scripts/memory_guardian.py status --workspace tests/.tmp_workspaces/cli_verification`
- `python scripts/memory_guardian.py run --dry-run --workspace tests/.tmp_workspaces/cli_verification`
- `python scripts/memory_guardian.py migrate --dry-run --no-backup --workspace tests/.tmp_workspaces/cli_verification`
- `python scripts/memory_guardian.py migrate-042 --apply --dry-run --workspace tests/.tmp_workspaces/cli_verification`

## v0.4.2 - 2026-03-29
- Completed deep refactor Task 1-9 on the existing `scripts/` entrypoints without switching the default storage backend.
- Added the `mg_schema`, `mg_repo`, `mg_events`, `mg_state`, and `mg_app` layers to centralize schema normalization, persistence, telemetry, state transitions, and ingest orchestration.
- Landed bootstrap-to-case migration, high-importance protection, typed decay, quiet degradation rules, provenance audit, snapshot support, and initial suite decomposition while keeping `tests/test_all.py` as a compatibility runner.
- Added dedicated regression suites for migration, protection, schema normalization, telemetry, compaction, ingest service, decay classification, quality gate state, snapshot/events, CLI coverage, and full quality gate coverage.
- Hardened the test suite for the current Windows sandbox by routing temporary workspaces through a repository-local helper instead of `tempfile.mkdtemp()`.

### Verification
- `python -m unittest discover -s tests -p "test_*.py" -v`
- `python tests/test_all.py`
- `python scripts/memory_guardian.py status --workspace <workspace>`
- `python scripts/memory_guardian.py run --dry-run --workspace <workspace>`
- `python scripts/dataflow_report.py --workspace <workspace>`


path: mcp_server.py
content: #!/usr/bin/env python3
"""memory-guardian MCP Server v0.4.6

Provides 10 structured tools (including run_batch + memory_sync) for the memory-guardian system.
Wraps the existing 19-script layer via direct Python imports (zero API calls).

Transport: stdio (default) | CLI fallback (--batch)
Design: skill-design-proposal-v3.md v3.2
"""

import argparse
import io
import json
import math
import os
import sys
import time
from contextlib import contextmanager
from datetime import datetime, timezone, timedelta

# ─── Script layer imports ────────────────────────────────────
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(SCRIPT_DIR, "scripts"))

from mg_utils import (
    CST, _now_iso, load_meta, save_meta, tokenize,
    get_effective_importance, is_protected_memory,
)


def _to_bool(val, default=False):
    """Safely convert a value to bool. Handles string 'true'/'false'."""
    if val is None:
        return default
    if isinstance(val, bool):
        return val
    if isinstance(val, str):
        return val.lower() in ("true", "1", "yes", "on")
    return bool(val)
from mg_schema import normalize_meta, MEMORY_DEFAULTS

# Lazy imports for heavy modules
def _import_memory_guardian():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "memory_guardian", os.path.join(SCRIPT_DIR, "scripts", "memory_guardian.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def _import_memory_decay():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "memory_decay", os.path.join(SCRIPT_DIR, "scripts", "memory_decay.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def _import_memory_ingest():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "memory_ingest", os.path.join(SCRIPT_DIR, "scripts", "memory_ingest.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def _import_memory_compact():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "memory_compact", os.path.join(SCRIPT_DIR, "scripts", "memory_compact.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def _import_quality_gate():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "quality_gate", os.path.join(SCRIPT_DIR, "scripts", "quality_gate.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def _import_case_invalidate():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "case_invalidate", os.path.join(SCRIPT_DIR, "scripts", "case_invalidate.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def _import_memory_sync():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "memory_sync", os.path.join(SCRIPT_DIR, "scripts", "memory_sync.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

# ─── References integrity check ─────────────────────────────
REFERENCES_DIR = os.path.join(SCRIPT_DIR, "references")
EXPECTED_REFS = [
    "triggers.md", "parameters.md", "compaction.md",
    "error_recovery.md", "case-management.md", "advanced-tools.md",
]

def check_references():
    """Scan references/ directory and return missing files list."""
    if not os.path.isdir(REFERENCES_DIR):
        return list(EXPECTED_REFS)
    missing = [f for f in EXPECTED_REFS
               if not os.path.isfile(os.path.join(REFERENCES_DIR, f))]
    return missing


# ─── Error helpers ───────────────────────────────────────────
class MCPError(Exception):
    """Structured error for MCP tool responses."""
    CODES = {
        "WORKSPACE_NOT_FOUND": "Workspace path does not exist",
        "META_CORRUPT": "meta.json format error — run bootstrap to reinitialize",
        "SCRIPT_ERROR": "Script layer raised an exception",
        "PERMISSION_DENIED": "File permission denied — check workspace directory permissions",
        "TIMEOUT": "Operation timed out — increase timeout or reduce processing load",
        "REFERENCES_MISSING": "references/ files missing — core functions still work, diagnostics degraded",
        "INVALID_PARAMS": "Invalid parameters provided",
        "NOT_FOUND": "Requested resource not found",
    }

    def __init__(self, code, message=None, hint=None):
        self.code = code
        self.message = message or self.CODES.get(code, "Unknown error")
        self.hint = hint
        super().__init__(self.message)

    def to_dict(self):
        result = {"error": True, "code": self.code, "message": self.message}
        if self.hint:
            result["hint"] = self.hint
        return result


# ─── Workspace resolver ──────────────────────────────────────
DEFAULT_WORKSPACE = os.environ.get(
    "OPENCLAW_WORKSPACE", os.environ.get("MG_WORKSPACE",
    os.path.expanduser("~/workspace/agent/workspace")))


def _validate_workspace_path(ws):
    """Validate workspace path against path traversal attacks.

    Normalizes the path via realpath and rejects any path that escapes
    its expected parent (user home or explicitly allowed root).
    """
    real = os.path.realpath(ws)
    # Reject any resolved path containing unexpected traversal
    parts = os.path.normpath(real).split(os.sep)
    if ".." in parts:
        raise ValueError(
            f"Workspace path contains traversal components: {ws}")
    return real


def resolve_workspace(params):
    """Resolve workspace from params or env, auto-create only when 'auto'."""
    ws = params.get("workspace", "auto")
    if ws == "auto":
        ws = os.environ.get("MG_WORKSPACE", DEFAULT_WORKSPACE)
    else:
        # Validate user-supplied path before any filesystem access
        ws = _validate_workspace_path(ws)
        if not os.path.exists(os.path.join(ws, "memory", "meta.json")):
            raise FileNotFoundError(f"Workspace not found: {ws}/memory/meta.json")

    # Always normalize the final workspace path
    ws = os.path.realpath(ws)
    os.makedirs(ws, exist_ok=True)
    return ws


def meta_path(workspace):
    return os.path.join(workspace, "memory", "meta.json")


# ─── Tool handlers ───────────────────────────────────────────
@contextmanager
def _capture_stdout():
    """Context manager to safely redirect stdout, restoring on exit."""
    captured = io.StringIO()
    old_stdout = sys.stdout
    sys.stdout = captured
    try:
        yield captured
    finally:
        sys.stdout = old_stdout


class MemoryGuardianServer:
    """MCP server wrapping the 19-script memory-guardian layer."""

    def __init__(self, workspace=None):
        self.workspace = workspace or DEFAULT_WORKSPACE

    @staticmethod
    def _ensure_schema_compliance(mp, workspace=None):
        """Ensure all memories comply with current schema.

        Fixes:
        1. String-typed numeric fields (importance, decay_score, confidence)
        2. Missing per-memory v0.4.5 fields (memory_id, file_path, etc.)
        3. Missing memory files on disk (writes them if content available)

        Called at the start of every run_batch. This is the single
        auto-upgrade path — users never need to run migration scripts manually.
        """
        try:
            import logging
            meta = load_meta(mp)
            fixed = False
            files_written = 0

            for m in meta.get("memories", []):
                # --- Fix 1: String-typed numeric fields ---
                for field in ("importance", "decay_score", "confidence"):
                    val = m.get(field)
                    if isinstance(val, str):
                        try:
                            m[field] = float(val)
                            fixed = True
                        except ValueError:
                            defaults = {"importance": 0.5, "decay_score": 0.5, "confidence": 0.45}
                            if field in defaults:
                                m[field] = defaults[field]
                                fixed = True
                # Fix nested quality_gate.confidence too
                qg = m.get("quality_gate", {})
                if isinstance(qg.get("confidence"), str):
                    try:
                        qg["confidence"] = float(qg["confidence"])
                        fixed = True
                    except ValueError:
                        pass

                # --- Fix 2: Missing v0.4.5 per-memory fields ---
                for key, default in MEMORY_DEFAULTS.items():
                    if key not in m:
                        m[key] = default
                        fixed = True

                # --- Fix 3: Missing memory_id ---
                if not m.get("memory_id"):
                    try:
                        from mg_utils import generate_memory_id
                        existing_ids = {
                            mm.get("memory_id") or mm.get("id")
                            for mm in meta.get("memories", [])
                            if mm.get("memory_id") or mm.get("id")
                        }
                        m["memory_id"] = generate_memory_id(
                            m.get("content", ""), existing_ids=existing_ids
                        )
                        fixed = True
                    except Exception:
                        m["memory_id"] = m.get("id", "unknown")
                        fixed = True

                # --- Fix 4: Missing file_path ---
                if not m.get("file_path"):
                    try:
                        from mg_utils import derive_file_path
                        tags = m.get("tags", [])
                        m["file_path"] = derive_file_path(
                            m["memory_id"], tags, m.get("content", "")
                        )
                        fixed = True
                    except Exception:
                        pass

                # --- Fix 5: Missing classification dict ---
                if not isinstance(m.get("classification"), dict):
                    tags = m.get("tags", [])
                    m["classification"] = {
                        "primary_tag": tags[0] if tags else "misc",
                        "confidence": m.get("classification_confidence", 0.5) or 0.5,
                        "needs_review": False,
                    }
                    fixed = True

            if fixed:
                save_meta(mp, meta)

            # --- Fix 6: Missing files on disk ---
            if workspace:
                try:
                    from memory_ingest import _write_memory_file
                    for m in meta.get("memories", []):
                        fp = m.get("file_path", "")
                        if not fp:
                            continue
                        abs_path = os.path.join(workspace, fp)
                        if not os.path.exists(abs_path) and m.get("content"):
                            try:
                                _write_memory_file(
                                    m["memory_id"], m["content"], fp, workspace=workspace
                                )
                                files_written += 1
                            except Exception:
                                logging.warning(
                                    "_ensure_schema_compliance: failed to write %s", fp
                                )
                except ImportError:
                    pass

            if fixed or files_written:
                print(
                    f"[memory-guardian] Schema compliance: fixed {sum(1 for _ in [])} type issues, "
                    f"wrote {files_written} missing files",
                    file=sys.stderr,
                )
            return fixed or files_written > 0

        except Exception as exc:
            print(f"[memory-guardian] _ensure_schema_compliance error: {exc}", file=sys.stderr)
            return False

    # ── Query tools ──────────────────────────────────────────

    def handle_memory_status(self, params):
        """memory_status — system overview."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)

            if not os.path.exists(mp):
                return {
                    "active": 0, "archived": 0, "observing": 0,
                    "memory_md_kb": 0, "gate_state": "NORMAL",
                    "case_summary": {"total": 0, "frozen": 0, "active": 0, "retired": 0},
                    "last_decay": None, "meta_path": mp,
                    "references": {"complete": True, "missing": []},
                }

            meta = load_meta(mp)
            meta, _ = normalize_meta(meta, read_only=True)

            memories = meta.get("memories", [])
            counts = {"active": 0, "archived": 0, "observing": 0,
                      "deleted": 0, "frozen": 0, "retired": 0}
            for m in memories:
                s = m.get("status", "active")
                if s in counts:
                    counts[s] += 1

            cases = [m for m in memories if m.get("id", "").startswith("case_")]
            case_summary = {
                "total": len(cases),
                "frozen": sum(1 for c in cases if c.get("status") == "frozen"),
                "active": sum(1 for c in cases if c.get("status") == "active"),
                "retired": sum(1 for c in cases if c.get("status") in ("archived", "deleted")),
            }

            # Gate state
            try:
                qg = _import_quality_gate()
                gate = qg.get_gate_state(meta)
                gate_state = gate.get("state", "NORMAL")
            except Exception:
                gate_state = "UNKNOWN"

            # Memory.md size
            memory_md = os.path.join(ws, "MEMORY.md")
            memory_md_kb = 0
            if os.path.exists(memory_md):
                memory_md_kb = round(os.path.getsize(memory_md) / 1024, 1)

            # Last decay
            last_decay = meta.get("last_decay_run", None)

            # References check
            missing = check_references()
            refs_info = {
                "complete": len(missing) == 0,
                "missing": missing,
            }

            return {
                "active": counts["active"],
                "archived": counts["archived"],
                "observing": counts["observing"],
                "memory_md_kb": memory_md_kb,
                "gate_state": gate_state,
                "case_summary": case_summary,
                "last_decay": last_decay,
                "meta_path": mp,
                "references": refs_info,
            }

        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e), "Check workspace path and meta.json integrity")

    def handle_memory_query(self, params):
        """memory_query — search memories with filters."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)

            if not os.path.exists(mp):
                return {"total": 0, "results": []}

            meta = load_meta(mp)
            memories = meta.get("memories", [])

            # Filters
            mem_type_filter = params.get("type_filter", params.get("type", "active"))
            query = params.get("query", "").lower().strip()
            min_score = float(params.get("min_score", 0.0))
            max_score = float(params.get("max_score", 1.0))
            mem_memory_type = params.get("memory_type")  # static/derive/absorb/suspend

            results = []
            for m in memories:
                status = m.get("status", "active")

                # Type filter
                if mem_type_filter == "active" and status != "active":
                    continue
                elif mem_type_filter == "archived" and status != "archived":
                    continue
                elif mem_type_filter == "observing" and status != "observing":
                    continue
                elif mem_type_filter == "all":
                    pass  # show all non-deleted
                elif mem_type_filter != "all":
                    if status != mem_type_filter:
                        continue

                if status == "deleted":
                    continue

                # Memory type filter
                if mem_memory_type:
                    if m.get("memory_type") != mem_memory_type:
                        continue

                # Score filter
                try:
                    score = float(m.get("decay_score", m.get("importance", 0.5)))
                except (ValueError, TypeError):
                    score = 0.5  # fallback for non-numeric values like "auto"
                if score < min_score or score > max_score:
                    continue

                # Query keyword filter
                if query:
                    content = m.get("content", "").lower()
                    tags = " ".join(m.get("tags", [])).lower()
                    if query not in content and query not in tags:
                        continue

                results.append({
                    "id": m.get("id", "?"),
                    "content_preview": m.get("content", "")[:120],
                    "score": round(score, 4),
                    "type": status,
                    "memory_type": m.get("memory_type"),
                    "importance": m.get("importance"),
                    "tags": m.get("tags", []),
                    "last_access": m.get("last_accessed"),
                    "trigger_count": m.get("trigger_count", 0),
                    "confidence": m.get("confidence"),
                })

            # Sort by score descending
            results.sort(key=lambda x: x["score"], reverse=True)

            return {"total": len(results), "results": results}

        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    # ── Action tools ─────────────────────────────────────────

    def handle_memory_decay(self, params):
        """memory_decay — execute decay calculation."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)
            lam = float(params.get("lambda", 0.01))
            dry_run = _to_bool(params.get("dry_run"), False)

            if not os.path.exists(mp):
                raise MCPError("META_CORRUPT",
                    "meta.json not found",
                    "Run bootstrap first: python3 scripts/memory_guardian.py bootstrap")

            md = _import_memory_decay()
            # Fix string-typed numeric fields before decay reads them
            self._ensure_schema_compliance(mp, workspace=ws)
            # Capture script-level prints to avoid polluting JSON output
            captured = io.StringIO()
            old_stdout = sys.stdout
            sys.stdout = captured
            try:
                result = md.run(mp, lam, dry_run)
            finally:
                sys.stdout = old_stdout

            # Convert script output to structured response
            changes = result.get("changes", [])
            details = []
            for c in changes[:20]:
                delta = c["new_score"] - c["old_score"]
                reason_parts = []
                if abs(delta) > 0.001:
                    reason_parts.append(f"score_change: {c['old_score']:.3f} -> {c['new_score']:.3f}")
                if c.get("cooling"):
                    reason_parts.append("cooling_active")
                if c.get("zombie"):
                    reason_parts.append("zombie_detected")
                if c.get("archive_mark"):
                    reason_parts.append("beta_archive_candidate")
                details.append({
                    "id": c["id"],
                    "old_score": c["old_score"],
                    "new_score": c["new_score"],
                    "reason": "; ".join(reason_parts) if reason_parts else "no_change",
                })

            # Update last_decay_run
            if not dry_run:
                meta = load_meta(mp)
                meta["last_decay_run"] = _now_iso()
                save_meta(mp, meta)

            return {
                "processed": len(changes),
                "changed": sum(1 for c in changes if abs(c["new_score"] - c["old_score"]) > 0.001),
                "details": details,
                "dry_run": dry_run,
                "summary": {
                    "to_archive": result.get("to_archive", 0),
                    "to_delete": result.get("to_delete", 0),
                    "cooling_count": result.get("cooling_count", 0),
                    "zombie_count": result.get("zombie_count", 0),
                },
            }

        except MCPError:
            raise
        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    def handle_memory_ingest(self, params):
        """memory_ingest — write a new memory."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)
            content = params.get("content", "").strip()

            if not content:
                raise MCPError("INVALID_PARAMS", "content is required")

            importance = params.get("importance", "auto")
            tags = params.get("tags", [])

            # Validate importance
            if importance != "auto":
                try:
                    imp_val = float(importance)
                    if imp_val < 0 or imp_val > 1.0:
                        raise MCPError("INVALID_PARAMS",
                            f"importance must be between 0.0 and 1.0, got {importance}")
                except ValueError:
                    raise MCPError("INVALID_PARAMS",
                        f"importance must be a number or 'auto', got {importance}")

            # Build ingest args
            # tags may come as JSON string from MCP caller
            if isinstance(tags, str):
                try:
                    tags = json.loads(tags)
                except (json.JSONDecodeError, TypeError):
                    tags = [t.strip() for t in tags.split(",") if t.strip()]
            if not isinstance(tags, list):
                tags = [tags]

            ingest = _import_memory_ingest()
            result = ingest.run(
                content=content,
                importance=importance,
                tags=tags,
                meta_path=mp,
                workspace=ws,
                provenance_source=params.get("provenance_source", "human_direct"),
                situation=params.get("situation"),
                judgment=params.get("judgment"),
                consequence=params.get("consequence"),
                action_conclusion=params.get("action_conclusion"),
                reversibility=params.get("reversibility"),
                boundary_words=params.get("boundary_words"),
                alternatives=params.get("alternatives"),
            )

            if isinstance(result, dict):
                return result

            # Parse text output if script returns string
            return {
                "status": "ingested",
                "content_preview": content[:100],
                "importance": importance,
                "tags": tags,
                "raw_output": str(result)[:500] if result else None,
            }

        except MCPError:
            raise
        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    def handle_memory_compact(self, params):
        """memory_compact — compress memories."""
        try:
            ws = resolve_workspace(params)
            dry_run = _to_bool(params.get("dry_run"), False)
            aggressive = _to_bool(params.get("aggressive"), False)
            max_kb = int(params.get("max_memory_kb", 15))

            compact = _import_memory_compact()
            # Capture script-level prints to avoid polluting JSON output
            with _capture_stdout() as captured:
                result = compact.run(ws, dry_run=dry_run, auto=not dry_run,
                                     max_memory_kb=max_kb, aggressive=aggressive)

            if isinstance(result, dict):
                # Script returns structured dict directly
                before = result.get("memory_size", result.get("memory_size_before", 0))
                after = result.get("memory_size_after", before)
                return {
                    "before_kb": round(before / 1024, 1),
                    "after_kb": round(after / 1024, 1),
                    "sections": result.get("sections", []),
                    "internal_duplicates": result.get("internal_duplicates", 0),
                    "rotated": result.get("rotated", False),
                    "rotated_count": result.get("rotated_count", 0),
                    "dry_run": dry_run,
                }

            # Fallback: parse text output
            return {
                "before_kb": 0,
                "after_kb": 0,
                "dry_run": dry_run,
                "raw_output": str(result)[:1000] if result else None,
            }

        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    def handle_memory_sync(self, params):
        """memory_sync — sync file changes to meta.json."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)
            dry_run = _to_bool(params.get("dry_run"), True)

            if not os.path.exists(mp):
                return {
                    "created": 0, "updated": 0, "skipped": 0,
                    "details": [], "dry_run": dry_run,
                    "message": "meta.json not found — run bootstrap first",
                }

            ms = _import_memory_sync()
            with _capture_stdout() as captured:
                result = ms.run(workspace=ws, dry_run=dry_run, meta_path_override=mp)

            return {
                "created": result.get("created", 0),
                "updated": result.get("updated", 0),
                "skipped": result.get("skipped", 0),
                "total_candidates": result.get("total_candidates", 0),
                "details": result.get("details", [])[:20],
                "dry_run": dry_run,
            }

        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    # ── Review tools ─────────────────────────────────────────

    def handle_quality_check(self, params):
        """quality_check — quality gate status."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)
            layer = params.get("layer", "all")

            if not os.path.exists(mp):
                return {
                    "state": "NORMAL", "anomaly_count": 0,
                    "intervention_level": "L0", "pending_l3": [],
                    "critical_history": {"total_critical": 0, "retired_count": 0, "retire_rate": 0, "recent_cases": []},
                    "stale_cases": [], "similar_case_signal": {"has_active_ref": False, "all_retired": False, "no_similar": True, "top3": []},
                    "details": [], "layer": layer,
                }

            meta = load_meta(mp)
            meta, _ = normalize_meta(meta, read_only=True)

            qg = _import_quality_gate()
            gate = qg.get_gate_state(meta)
            state = gate.get("state", "NORMAL")

            # Anomaly count
            anomaly_count = gate.get("anomaly_count", 0)
            total_failures = gate.get("total_failures", 0)

            # Intervention level
            intervention = "L0"
            if state == "WARNING":
                intervention = "L1"
            elif state == "CRITICAL":
                l3_pending = gate.get("l3_pending_queue", [])
                if l3_pending:
                    intervention = "L3"
                else:
                    intervention = "L2"
            elif state == "RECOVERING":
                intervention = "L2"

            # Critical history
            memories = meta.get("memories", [])
            critical_cases = [m for m in memories
                              if m.get("id", "").startswith("case_")
                              and m.get("status") in ("frozen", "archived", "deleted")
                              and m.get("frozen_at")]
            total_critical = len(critical_cases)
            retired_count = sum(1 for c in critical_cases if c.get("status") in ("archived", "deleted"))
            retire_rate = round(retired_count / max(total_critical, 1), 4)

            recent_cases = []
            for c in sorted(critical_cases, key=lambda x: x.get("frozen_at", ""), reverse=True)[:5]:
                recent_cases.append({
                    "case_id": c.get("id"),
                    "trigger_count": c.get("trigger_count", 0),
                    "first_at": c.get("created_at"),
                    "last_at": c.get("frozen_at"),
                    "status": c.get("status"),
                })

            # Stale cases
            ci = _import_case_invalidate()
            stale_cases = []
            now_dt = datetime.now(CST)
            for m in memories:
                if m.get("status") != "frozen":
                    continue
                last_change = m.get("frozen_at") or m.get("last_accessed")
                if last_change:
                    try:
                        last_dt = datetime.fromisoformat(last_change)
                        hours_since = (now_dt - last_dt).total_seconds() / 3600
                        if hours_since > 18:  # ~6 heartbeats
                            stale_cases.append({
                                "case_id": m.get("id"),
                                "no_change_heartbeats": round(hours_since / 3),
                                "last_confidence": m.get("confidence", 0),
                                "last_trigger": last_change,
                            })
                    except (ValueError, TypeError):
                        pass

            # Similar case signal
            similar_signal = self._compute_similar_case_signal(meta)

            # Layer details
            details = []
            if layer in ("all", "security"):
                details.append({"layer": "security", "check": "rules_loaded", "status": "PASS"})
            if layer in ("all", "dedup"):
                details.append({"layer": "dedup", "check": "overlap_scan", "status": "PASS"})
            if layer in ("all", "ingest"):
                details.append({"layer": "ingest", "check": "quality_gate", "status": "PASS"})
            if layer in ("all", "decay"):
                details.append({"layer": "decay", "check": "beta_stability", "status": "PASS"})

            # Mark anomalies in details
            if anomaly_count > 0:
                for d in details:
                    if d["layer"] == "decay":
                        d["status"] = "FAIL"
                        d["message"] = f"anomaly_count={anomaly_count}"

            # Read critical_history for consecutive-round detection (read-only)
            if retire_rate > 0.3:
                history = meta.get("critical_history", [])
                recent_window = [h for h in history if h.get("retire_rate", 0) > 0.3]
                consecutive = len(recent_window)
                if consecutive >= 3:
                    intervention = "L3"

            return {
                "state": state,
                "anomaly_count": anomaly_count,
                "intervention_level": intervention,
                "pending_l3": gate.get("l3_pending_queue", []),
                "critical_history": {
                    "total_critical": total_critical,
                    "retired_count": retired_count,
                    "retire_rate": retire_rate,
                    "rule_review_suggested": retire_rate > 0.3,
                    "recent_cases": recent_cases,
                },
                "stale_cases": stale_cases[:10],
                "similar_case_signal": similar_signal,
                "details": details,
                "layer": layer,
            }

        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    def handle_case_query(self, params):
        """case_query — query cases."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)
            filter_val = params.get("filter", params.get("status", "all"))
            min_confidence = float(params.get("min_confidence", 0.0))

            if not os.path.exists(mp):
                return {"total": 0, "cases": []}

            meta = load_meta(mp)
            memories = meta.get("memories", [])
            cases = [m for m in memories if m.get("id", "").startswith("case_")]

            # Filter
            now_dt = datetime.now(CST)
            results = []
            for c in cases:
                status = c.get("status", "active")
                confidence = c.get("confidence", 0.5)

                if confidence < min_confidence:
                    continue

                if filter_val == "active" and status != "active":
                    continue
                elif filter_val == "frozen" and status != "frozen":
                    continue
                elif filter_val == "observing" and status != "observing":
                    continue
                elif filter_val == "retired" and status not in ("archived", "deleted"):
                    continue
                elif filter_val == "stale":
                    last_change = c.get("frozen_at") or c.get("last_accessed")
                    if last_change:
                        try:
                            last_dt = datetime.fromisoformat(last_change)
                            hours = (now_dt - last_dt).total_seconds() / 3600
                            if hours < 18:
                                continue
                        except (ValueError, TypeError):
                            continue
                    else:
                        continue
                elif filter_val == "ignored":
                    if not c.get("ignore_at"):
                        continue

                results.append({
                    "id": c.get("id"),
                    "confidence": round(confidence, 4),
                    "status": status,
                    "trigger_count": c.get("trigger_count", 0),
                    "last_trigger": c.get("last_triggered"),
                    "frozen_at": c.get("frozen_at"),
                    "ignore_at": c.get("ignore_at"),
                    "tags": c.get("tags", []),
                    "content_preview": c.get("content", "")[:100],
                })

            results.sort(key=lambda x: x.get("confidence", 0), reverse=True)

            return {"total": len(results), "cases": results}

        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    def handle_case_review(self, params):
        """case_review — perform case action."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)
            case_id = params.get("case_id", "").strip()
            action = params.get("action", "").strip().lower()
            origin_type = params.get("origin_type", "agent_initiated")

            if not case_id:
                raise MCPError("INVALID_PARAMS", "case_id is required")
            if action not in ("active", "frozen", "freeze", "retired", "unfreeze", "ignore"):
                raise MCPError("INVALID_PARAMS",
                    f"Invalid action: {action}. Must be one of: active, frozen/freeze, retired, unfreeze, ignore")
            # Alias: freeze → frozen
            if action == "freeze":
                action = "frozen"

            if not os.path.exists(mp):
                raise MCPError("META_CORRUPT", "meta.json not found")

            ci = _import_case_invalidate()
            now = _now_iso()

            if action == "unfreeze":
                # Get actual status before action
                meta_tmp = load_meta(mp)
                mem_tmp = next((m for m in meta_tmp.get("memories", []) if m.get("id") == case_id), None)
                if not mem_tmp:
                    raise MCPError("NOT_FOUND", f"Case {case_id} not found")
                old_status = mem_tmp.get("status", "unknown")
                if old_status != "frozen":
                    raise MCPError("INVALID_PARAMS",
                        f"Cannot unfreeze case in '{old_status}' status (must be frozen)")
                result = ci.unfreeze(mp, case_id)
                # Script returns "unfrozen" in result, but meta is written as "active"
                new_status = "active"
                return {
                    "case_id": case_id,
                    "old_status": old_status,
                    "new_status": new_status,
                    "updated_fields": ["status", "confidence", "consecutive_low_confidence"],
                    "origin_type": origin_type,
                }

            elif action == "retired":
                # retired → delegates to case_invalidate.archive_case script layer
                meta_tmp = load_meta(mp)
                mem_tmp = next((m for m in meta_tmp.get("memories", []) if m.get("id") == case_id), None)
                if not mem_tmp:
                    raise MCPError("NOT_FOUND", f"Case {case_id} not found")
                old_status = mem_tmp.get("status", "unknown")
                result = ci.archive_case(mp, case_id)
                return {
                    "case_id": case_id,
                    "old_status": old_status,
                    "new_status": "archived",
                    "updated_fields": ["status", "archived_at", "archive_reason"],
                    "origin_type": origin_type,
                }

            elif action == "ignore":
                # ignore — direct MCP-layer operation (no script equivalent).
                # Sets ignore_at timestamp and updates review queue in meta.json.
                # The script layer (case_invalidate) only handles archive/unfreeze,
                # so lightweight status tweaks are handled here for consistency.
                meta = load_meta(mp)
                mem = None
                for m in meta.get("memories", []):
                    if m.get("id") == case_id:
                        mem = m
                        break
                if not mem:
                    raise MCPError("NOT_FOUND", f"Case {case_id} not found")
                old_status = mem.get("status", "unknown")
                mem["ignore_at"] = now
                # Update review queue
                for r in meta.get("case_review_queue", []):
                    if r["case_id"] == case_id and r["status"] == "pending_review":
                        r["status"] = "ignored"
                        r["resolved_at"] = now
                        r["action"] = "ignore"
                        break
                save_meta(mp, meta)
                return {
                    "case_id": case_id,
                    "old_status": old_status,
                    "new_status": old_status,  # status unchanged, just marked ignored
                    "updated_fields": ["ignore_at"],
                    "origin_type": origin_type,
                }

            elif action == "active":
                # active — direct MCP-layer operation (no script equivalent).
                # Reactivates a case by setting status=active and boosting confidence.
                # Kept at MCP layer because this is a simple status toggle that
                # doesn't require the case_invalidate script's archive logic.
                meta = load_meta(mp)
                mem = None
                for m in meta.get("memories", []):
                    if m.get("id") == case_id:
                        mem = m
                        break
                if not mem:
                    raise MCPError("NOT_FOUND", f"Case {case_id} not found")
                old_status = mem.get("status", "unknown")
                mem["status"] = "active"
                mem["confidence"] = max(mem.get("confidence", 0.3), 0.5)
                mem["consecutive_low_confidence"] = 0
                save_meta(mp, meta)
                return {
                    "case_id": case_id,
                    "old_status": old_status,
                    "new_status": "active",
                    "updated_fields": ["status", "confidence"],
                    "origin_type": origin_type,
                }

            elif action == "frozen":
                # frozen — direct MCP-layer operation (no script equivalent).
                # Freezes a case to prevent automatic decay/archival.
                # The script layer only handles archive/unfreeze; freeze is
                # a lightweight status change managed entirely at MCP layer.
                meta = load_meta(mp)
                mem = None
                for m in meta.get("memories", []):
                    if m.get("id") == case_id:
                        mem = m
                        break
                if not mem:
                    raise MCPError("NOT_FOUND", f"Case {case_id} not found")
                old_status = mem.get("status", "unknown")
                if old_status not in ("observing", "active"):
                    raise MCPError("INVALID_PARAMS",
                        f"Cannot freeze case in '{old_status}' status (must be observing or active)")
                mem["status"] = "frozen"
                mem["frozen_at"] = now
                mem["frozen_reason"] = f"manual_freeze via case_review (origin={origin_type})"
                save_meta(mp, meta)
                return {
                    "case_id": case_id,
                    "old_status": old_status,
                    "new_status": "frozen",
                    "updated_fields": ["status", "frozen_at", "frozen_reason"],
                    "origin_type": origin_type,
                }

        except MCPError:
            raise
        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    # ── Batch mode ───────────────────────────────────────────

    def handle_run_batch(self, params):
        """run_batch — full maintenance cycle."""
        try:
            ws = resolve_workspace(params)
            mp = meta_path(ws)
            apply = _to_bool(params.get("apply"), True)
            dry_run = _to_bool(params.get("dry_run"), False)
            # apply=False means no writes — treat as dry_run
            if not apply:
                dry_run = True
            skip_compact = _to_bool(params.get("skip_compact"), False)
            timeout = int(params.get("timeout", 300))
            bootstrapped = False
            batch_start = time.monotonic()

            def _check_timeout(step_name):
                """Raise MCPError if batch has exceeded the configured timeout."""
                elapsed = time.monotonic() - batch_start
                if elapsed >= timeout:
                    raise MCPError(
                        "TIMEOUT",
                        f"Batch timed out at step '{step_name}' after {elapsed:.1f}s "
                        f"(limit: {timeout}s)",
                        hint="Increase the timeout parameter or reduce processing load.",
                    )

            if not os.path.exists(mp):
                # Auto-bootstrap (capture print output)
                mg = _import_memory_guardian()
                with _capture_stdout():
                    mg.cmd_bootstrap(ws)
                bootstrapped = True

            results = {}

            # Pre-flight: normalize meta to fix type issues (e.g. string importance)
            self._ensure_schema_compliance(mp, workspace=ws)

            # Step 0: Sync files → meta.json
            _check_timeout("sync")
            sync_result = self.handle_memory_sync({
                "workspace": ws, "dry_run": dry_run,
            })
            results["sync_summary"] = {
                "created": sync_result.get("created", 0),
                "skipped": sync_result.get("skipped", 0),
            }

            # Step 1: Decay
            _check_timeout("decay")
            decay_result = self.handle_memory_decay({
                "workspace": ws, "lambda": 0.01, "dry_run": dry_run,
            })
            results["decay_summary"] = {
                "processed": decay_result.get("processed", 0),
                "changed": decay_result.get("changed", 0),
            }

            # Step 2: Quality check
            _check_timeout("quality_check")
            qc_result = self.handle_quality_check({
                "workspace": ws, "layer": "all",
            })
            results["gate_state"] = qc_result.get("state", "NORMAL")
            results["intervention_level"] = qc_result.get("intervention_level", "L0")
            results["rule_review_suggested"] = qc_result.get(
                "critical_history", {}).get("rule_review_suggested", False)

            # Step 3: Compact (optional)
            if not skip_compact and not dry_run:
                _check_timeout("compact")
                compact_result = self.handle_memory_compact({
                    "workspace": ws, "dry_run": dry_run,
                })
                results["compact_summary"] = {
                    "before_kb": compact_result.get("before_kb", 0),
                    "after_kb": compact_result.get("after_kb", 0),
                }
                results["compact_needed"] = compact_result.get("before_kb", 0) > 15
            else:
                results["compact_needed"] = False
                results["compact_summary"] = None

            # Collect violations
            try:
                meta = load_meta(mp)
                violations = []
                for v in meta.get("violation_events", [])[-5:]:
                    violations.append({
                        "rule": v.get("rule"),
                        "event": v.get("event", "")[:80],
                        "severity": v.get("severity"),
                    })
                results["violations"] = violations
            except Exception:
                results["violations"] = []

            results["recommendations"] = []
            results["dry_run"] = dry_run
            results["timestamp"] = _now_iso()
            results["bootstrap"] = bootstrapped

            return results

        except Exception as e:
            raise MCPError("SCRIPT_ERROR", str(e))

    # ── Internal helpers ─────────────────────────────────────

    def _compute_similar_case_signal(self, meta):
        """Compute similar_case_signal using tag-overlap matching.

        Three-level signal:
        - has_active_ref: >=1 similar case is active
        - all_retired: all similar cases are retired/frozen
        - no_similar: no similar cases found

        Uses tag overlap (Jaccard) instead of external embedding.
        Optimized with inverted tag index to avoid O(N²) full scan.
        """
        from collections import defaultdict

        cases = [m for m in meta.get("memories", [])
                 if m.get("id", "").startswith("case_")]
        if len(cases) < 2:
            return {"has_active_ref": False, "all_retired": False,
                    "no_similar": len(cases) == 0, "top3": []}

        # Build tag sets for each case
        case_tag_sets = {}
        for c in cases:
            tags = set(c.get("tags", []))
            # Also add content keywords
            content_words = set(tokenize(c.get("content", "")))
            case_tag_sets[c["id"]] = tags | content_words

        # Build inverted tag→case_index for efficient pair comparison
        tag_index = defaultdict(list)
        for i, c in enumerate(cases):
            for tag in case_tag_sets[c["id"]]:
                tag_index[tag].append(i)

        # Collect unique case pairs that share at least one tag
        compared = set()
        pair_similarities = defaultdict(list)  # target_idx → [(other_idx, sim)]

        for indices in tag_index.values():
            for a in range(len(indices)):
                for b in range(a + 1, len(indices)):
                    pair = (min(indices[a], indices[b]), max(indices[a], indices[b]))
                    if pair in compared:
                        continue
                    compared.add(pair)

                    idx_a, idx_b = pair
                    tags_a = case_tag_sets[cases[idx_a]["id"]]
                    tags_b = case_tag_sets[cases[idx_b]["id"]]
                    if not tags_a or not tags_b:
                        continue

                    # Jaccard similarity
                    intersection = len(tags_a & tags_b)
                    union = len(tags_a | tags_b)
                    if union == 0:
                        continue
                    sim = intersection / union

                    if sim > 0.1:  # Minimum threshold
                        pair_similarities[idx_a].append((idx_b, sim))
                        pair_similarities[idx_b].append((idx_a, sim))

        # For each case, find top-3 similar by tag overlap
        all_signals = {"has_active_ref": False, "all_retired": False,
                       "no_similar": True, "top3": []}

        # Aggregate signal across all cases (system-level)
        total_topic_similar = 0
        total_all_retired = 0

        for target_idx, target in enumerate(cases):
            target_tags = case_tag_sets.get(target["id"], set())
            if not target_tags:
                continue

            # Filter by topic_similarity > 0.6 (v3.2)
            topic_similar = []
            for other_idx, sim in pair_similarities.get(target_idx, []):
                other = cases[other_idx]
                if sim > 0.6:
                    topic_similar.append({
                        "case_id": other["id"],
                        "status": other.get("status", "active"),
                        "confidence": other.get("confidence", 0.5),
                        "topic_similarity": round(sim, 4),
                    })

            if topic_similar:
                all_signals["no_similar"] = False
                total_topic_similar += 1

                active_refs = [s for s in topic_similar if s["status"] == "active"]
                if active_refs:
                    all_signals["has_active_ref"] = True
                elif all(s["status"] in ("retired", "archived", "deleted", "frozen")
                         for s in topic_similar):
                    total_all_retired += 1

        # System-level all_retired: majority of cases with similar refs have all-retired
        if total_topic_similar > 0:
            all_signals["all_retired"] = total_all_retired > total_topic_similar * 0.5

        return all_signals


# ─── MCP Protocol Layer (stdio) ─────────────────────────────
def serve_stdio():
    """MCP stdio transport — read JSON-RPC, dispatch tools, write responses."""
    server = MemoryGuardianServer()

    # Tool registry (schemas use MCP JSON Schema format)
    TOOLS = {
        "memory_status": (server.handle_memory_status, {
            "type": "object",
            "properties": {"workspace": {"type": "string", "default": "auto"}},
        }),
        "memory_query": (server.handle_memory_query, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "query": {"type": "string", "default": ""},
                "type_filter": {"type": "string", "enum": ["active", "archived", "observing", "all"], "default": "active"},
                "min_score": {"type": "number", "default": 0.0},
                "max_score": {"type": "number", "default": 1.0},
                "memory_type": {"type": "string", "enum": ["static", "derive", "absorb", "suspend"]},
            },
        }),
        "memory_decay": (server.handle_memory_decay, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "lambda": {"type": "number", "default": 0.01},
                "dry_run": {"type": "boolean", "default": False},
            },
        }),
        "memory_ingest": (server.handle_memory_ingest, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "content": {"type": "string"},
                "importance": {"type": "string", "default": "auto"},
                "tags": {"type": "array", "items": {"type": "string"}, "default": []},
                "provenance_source": {"type": "string", "enum": ["human_direct", "system_induction", "external"]},
                "situation": {"type": "string", "description": "Case situation context"},
                "judgment": {"type": "string", "description": "Case judgment"},
                "consequence": {"type": "string", "description": "Case consequence"},
                "action_conclusion": {"type": "string", "description": "Case action/conclusion"},
                "reversibility": {"type": "string", "description": "Reversibility (high/medium/low)"},
                "boundary_words": {"type": "array", "items": {"type": "string"}, "description": "Boundary keywords"},
                "alternatives": {"type": "array", "items": {"type": "string"}, "description": "Alternatives considered"},
            },
            "required": ["content"],
        }),
        "memory_compact": (server.handle_memory_compact, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "dry_run": {"type": "boolean", "default": False},
                "aggressive": {"type": "boolean", "default": False},
                "max_memory_kb": {"type": "integer", "default": 15},
            },
        }),
        "quality_check": (server.handle_quality_check, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "layer": {"type": "string", "enum": ["all", "security", "dedup", "ingest", "decay"], "default": "all"},
            },
        }),
        "case_query": (server.handle_case_query, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "filter": {"type": "string", "enum": ["all", "active", "frozen", "observing", "retired", "stale", "ignored"], "default": "all"},
                "min_confidence": {"type": "number", "default": 0.0},
            },
        }),
        "case_review": (server.handle_case_review, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "case_id": {"type": "string"},
                "action": {"type": "string", "enum": ["active", "frozen", "retired", "unfreeze", "ignore"]},
                "origin_type": {"type": "string", "enum": ["agent_initiated", "notification_callback", "unknown"], "default": "agent_initiated"},
            },
            "required": ["case_id", "action"],
        }),
        "run_batch": (server.handle_run_batch, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "apply": {"type": "boolean", "default": True},
                "dry_run": {"type": "boolean", "default": False},
                "skip_compact": {"type": "boolean", "default": False},
                "timeout": {"type": "integer", "default": 300},
            },
        }),
        "memory_sync": (server.handle_memory_sync, {
            "type": "object",
            "properties": {
                "workspace": {"type": "string", "default": "auto"},
                "dry_run": {"type": "boolean", "default": True},
            },
        }),
    }

    tool_list = []
    for name, (_, schema) in TOOLS.items():
        tool_list.append({"name": name, "schema": schema})

    def _respond(response):
        """Write JSON-RPC response to stdout."""
        json_str = json.dumps(response, ensure_ascii=False)
        sys.stdout.write(json_str + "\n")
        sys.stdout.flush()

    # References integrity check on startup (neuro P0: fail-fast on missing refs)
    missing = check_references()
    if missing:
        print(f"[ERROR] references/ incomplete: missing {missing}. "
              f"Core functions work but diagnostics are degraded. "
              f"See SKILL.md references/ section for expected files.",
              file=sys.stderr)

    # Read JSON-RPC messages from stdin
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue

        try:
            msg = json.loads(line)
        except json.JSONDecodeError:
            _respond({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None})
            continue

        method = msg.get("method", "")
        req_id = msg.get("id")
        params = msg.get("params", {})

        if method == "initialize":
            _respond({
                "jsonrpc": "2.0",
                "result": {
                    "protocolVersion": "2024-11-05",
                    "capabilities": {"tools": {"listChanged": False}},
                    "serverInfo": {"name": "memory-guardian", "version": "0.4.6"},
                },
                "id": req_id,
            })

        elif method == "notifications/initialized":
            # Client acknowledges initialization — silently ignore (no id).
            # MCP spec: this is a notification, not a request.
            pass

        elif method == "shutdown":
            # MCP spec: return empty result, then exit.
            _respond({
                "jsonrpc": "2.0",
                "result": {},
                "id": req_id,
            })
            break  # Exit main loop; process will terminate

        elif method == "tools/list":
            _respond({
                "jsonrpc": "2.0",
                "result": {"tools": tool_list},
                "id": req_id,
            })

        elif method == "tools/call":
            tool_name = params.get("name", "")
            arguments = params.get("arguments", {})

            if tool_name not in TOOLS:
                _respond({
                    "jsonrpc": "2.0",
                    "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
                    "id": req_id,
                })
                continue

            handler, _ = TOOLS[tool_name]
            try:
                result = handler(arguments)
                _respond({
                    "jsonrpc": "2.0",
                    "result": {
                        "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}],
                    },
                    "id": req_id,
                })
            except MCPError as e:
                _respond({
                    "jsonrpc": "2.0",
                    "result": {
                        "content": [{"type": "text", "text": json.dumps(e.to_dict(), ensure_ascii=False, indent=2)}],
                        "isError": True,
                    },
                    "id": req_id,
                })
            except Exception as e:
                _respond({
                    "jsonrpc": "2.0",
                    "result": {
                        "content": [{"type": "text", "text": json.dumps(
                            MCPError("SCRIPT_ERROR", str(e)).to_dict(), ensure_ascii=False, indent=2)}],
                        "isError": True,
                    },
                    "id": req_id,
                })
        else:
            _respond({
                "jsonrpc": "2.0",
                "error": {"code": -32601, "message": f"Unknown method: {method}"},
                "id": req_id,
            })


# ─── CLI fallback ────────────────────────────────────────────
def cli_batch(args):
    """CLI fallback: bypass MCP protocol, call handlers directly."""
    server = MemoryGuardianServer(args.workspace)
    result = server.handle_run_batch({
        "workspace": args.workspace,
        "apply": args.apply,
        "dry_run": args.dry_run,
        "skip_compact": args.skip_compact,
        "timeout": args.timeout,
    })
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cli_tool(args):
    """CLI fallback: call a specific tool handler."""
    server = MemoryGuardianServer(args.workspace)
    tool_name = args.tool
    handler_map = {
        "memory_status": server.handle_memory_status,
        "memory_query": server.handle_memory_query,
        "memory_decay": server.handle_memory_decay,
        "memory_ingest": server.handle_memory_ingest,
        "memory_compact": server.handle_memory_compact,
        "quality_check": server.handle_quality_check,
        "case_query": server.handle_case_query,
        "case_review": server.handle_case_review,
        "run_batch": server.handle_run_batch,
        "memory_sync": server.handle_memory_sync,
    }
    handler = handler_map.get(tool_name)
    if not handler:
        print(f"[ERROR] Unknown tool: {tool_name}")
        print(f"Available: {', '.join(handler_map.keys())}")
        sys.exit(1)

    params = {}
    if args.params:
        params = json.loads(args.params)

    try:
        result = handler(params)
        print(json.dumps(result, indent=2, ensure_ascii=False))
    except MCPError as e:
        print(json.dumps(e.to_dict(), indent=2, ensure_ascii=False))
        sys.exit(1)


# ─── Main ────────────────────────────────────────────────────
if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="memory-guardian MCP Server v0.4.6")
    parser.add_argument("--batch", action="store_true",
                        help="CLI fallback: run batch maintenance")
    parser.add_argument("--tool", type=str,
                        help="CLI fallback: call specific tool (e.g. memory_status)")
    parser.add_argument("--params", type=str,
                        help="CLI fallback: JSON params for --tool")
    parser.add_argument("--workspace", type=str,
                        help="Workspace path (default: MG_WORKSPACE env or ~/workspace/agent/workspace)")
    parser.add_argument("--apply", action="store_true", help="Apply changes (batch mode)")
    parser.add_argument("--dry-run", action="store_true", help="Dry run (batch mode)")
    parser.add_argument("--skip-compact", action="store_true",
                        help="Skip compaction (batch mode)")
    parser.add_argument("--timeout", type=int, default=300,
                        help="Timeout in seconds (batch mode)")

    args = parser.parse_args()

    if args.batch:
        cli_batch(args)
    elif args.tool:
        cli_tool(args)
    else:
        # Default: MCP stdio mode
        serve_stdio()


path: README.md
content: # memory-guardian

`memory-guardian` is a local memory maintenance toolkit for Agent workspaces, providing a unified CLI and MCP Server around `meta.json`, snapshots, quality gates, decay, case migration, and dataflow observability. Current version offers dual entry points:
- **MCP Server** (`mcp_server.py`): 10 structured tools, stdio transport, for MCP clients like OpenClaw
- **CLI** (`scripts/memory_guardian.py`): Unified command-line entry point, for manual ops and cron scheduling
- **File Sync** (`scripts/memory_sync.py`): Auto-detect MEMORY.md / daily notes changes and sync to meta.json

The script layer (`scripts/`) has layered schema, repo, telemetry, state, and ingest service without switching the default storage backend.v0.4.5 introduces the **6-layer lazy-loading architecture** (Storage / Index / Routing / Signal / Decay / Migration), implemented via `mg_schema` / `mg_repo` / `mg_app` / `mg_events` / `mg_state` layered modules with on-demand imports to reduce cold-start overhead.New `memory_router` (tag-based routing) and `migrate_retag` (migration script) modules.

## Use Cases

- Maintaining long-term memory or case libraries based on `memory/meta.json`
- Migrating legacy `mem_*` records to more stable `case_*` data
- Governing write quality, anomaly states, L3 human confirmations, and invalidated cases
- Providing repeatable dry-run / apply ops workflows for Agent sessions
- Tracking per-module hit/miss, I/O volumes, and pipeline breakage candidates
- Lazy-loading architecture: 6-layer modules (Storage/Index/Routing/Signal/Decay/Migration) with on-demand imports
- Tag-based routing: `memory_router` distributes memories to index partitions by tag for fast tag queries
- Migration scripts: `migrate_retag` supports batch retagging and migration operations

## Current Capabilities

- **MCP Server**: 10 structured tools (memory_status / memory_query / memory_decay / memory_ingest / memory_compact / quality_check / case_query / case_review / run_batch / memory_sync), supporting stdio and CLI dual modes
- **6-Layer Lazy-Loading Architecture**: `mg_schema` (Storage) / `mg_repo` (Index) / `mg_app` (Application) / `mg_events` (Signal) / `mg_state` (Decay) / migration scripts, on-demand imports
- Tag-based routing: `memory_router` distributes memories to index partitions by tag for fast tag queries
- Auto file sync: `memory_sync` scans MEMORY.md and daily notes, filters by keywords + dedup check, auto-ingests to meta.json as Step 0 of run_batch
- Unified CLI entry point: status check, write, bootstrap, snapshot, maintenance workflow
- Schema normalization: `mg_schema.normalize_meta()` handles legacy field compatibility and default value filling
- Repo / App layering: `mg_repo` and `mg_app` encapsulate `meta.json` persistence and ingest orchestration
- Telemetry: `mg_events.telemetry` records module hits, I/O volumes, and reports
- Quality gate: supports `NORMAL / WARNING / CRITICAL / RECOVERING` state transitions
- L3 human confirmation: supports pending, confirm, reject, timeout downgrade
- PID adaptive: adjusts thresholds by scene group
- Case invalidation: freezes after consecutive low-confidence triggers, enters human review queue
- Snapshots & audit: `run` triggers `snapshot` for cross-time workspace comparison
- Agent Skill: conforms to OpenClaw AgentSkills spec with YAML frontmatter + 6 references/ lazy-loaded files
- File sync: `memory_sync` auto-executes as Step 0 of `run_batch`, 30-minute cooldown, keyword filtering + dedup
- Migration scripts: `migrate_retag` supports batch retagging and migration operations

## Project Structure

```text
memory-guardian/
├─ mcp_server.py              # MCP Server entry point (10 tools)
├─ scripts/
│  ├─ memory_guardian.py      # Unified CLI entry point
│  ├─ mg_utils.py             # Shared utilities: meta I/O, time, tokenization, protection rules
│  ├─ mg_schema/              # Storage layer: schema normalization and defaults
│  ├─ mg_repo/                # Index layer: meta.json persistence abstraction
│  ├─ mg_app/                 # Application layer: ingest service
│  ├─ mg_events/              # Signal layer: telemetry / dataflow stats
│  ├─ mg_state/               # Decay layer: quality gate state and rules
│  ├─ memory_router.py        # tag-based routing (distributes to index partitions by tag)
│  ├─ migrate_retag.py        # Batch retagging migration script
│  ├─ quality_gate.py         # Quality gate module
│  ├─ memory_sync.py          # File→meta.json auto sync
│  ├─ l3_confirm.py           # L3 human confirmation
│  ├─ pid_adaptive.py         # PID adaptive control
│  ├─ case_invalidate.py      # Case invalidation & review queue
│  └─ ...                     # decay / compact / diff / migrate and other scripts
├─ references/                # Skill lazy-loaded reference docs (6 files)
├─ tests/                     # unittest regression suite (581 tests)
├─ docs/                      # Design document
├─ CHANGELOG.md
├─ LICENSE                    # Mulan PSL v2
└─ SKILL.md                   # Agent Skill definition (YAML frontmatter)
```

## Workspace Conventions

Tools rely on the following workspace structure by default:

```text
<workspace>/
├─ MEMORY.md
├─ memory/
│  ├─ meta.json
│  └─ YYYY-MM-DD.md
└─ .memory-guardian/
   └─ diff-snapshots/
```

- Specify default workspace via `OPENCLAW_WORKSPACE` environment variable
- Or explicitly pass `--workspace <path>` with each command
- Always specify `--workspace` explicitly in automated environments to avoid writing to the default directory

## Quick Start

### 1. Check Current Status

```bash
python scripts/memory_guardian.py status --workspace <workspace>
```

This outputs:

- `meta.json` version and total memory count
- Count distribution by `status`
- Linked results from `memory_compact.py`, `memory_dedup.py`, `memory_decay.py --dry-run`

### 2. Preview a Full Maintenance Run

Maintenance workflow auto-includes file sync (sync → decay → compact → quality gate):

```bash
python scripts/memory_guardian.py run --dry-run --workspace <workspace>
```

Recommended: run dry-run first, then decide whether to apply:

```bash
python scripts/memory_guardian.py run --apply --workspace <workspace>
```

### 3. Write a Memory

```bash
python scripts/memory_guardian.py ingest \
  --content "Record a new experience" \
  --importance 0.7 \
  --tags "project,decision" \
  --workspace <workspace>
```

Also supports case field mode:

```bash
python scripts/memory_guardian.py ingest \
  --situation "When user asks to modify docs directly" \
  --judgment "First update README and usage docs" \
  --consequence "Reduces onboarding cost" \
  --action-conclusion "Organize commands first, then add workflow" \
  --workspace <workspace>
```

## Common Commands

### Main Entry `memory_guardian.py`

```bash
python scripts/memory_guardian.py status --workspace <workspace>
python scripts/memory_guardian.py run --dry-run --workspace <workspace>
python scripts/memory_guardian.py run --apply --workspace <workspace>
python scripts/memory_guardian.py ingest --content "..." --importance 0.7 --tags "project" --workspace <workspace>
python scripts/memory_guardian.py bootstrap --workspace <workspace>
python scripts/memory_guardian.py snapshot --workspace <workspace>
python scripts/memory_guardian.py migrate --dry-run --workspace <workspace>
python scripts/memory_guardian.py migrate --dry-run --no-backup --workspace <workspace>
python scripts/memory_guardian.py migrate-bootstrap --dry-run --workspace <workspace>
python scripts/memory_guardian.py migrate-042 --dry-run --workspace <workspace>
python scripts/memory_guardian.py violations-health --workspace <workspace>
python scripts/memory_guardian.py security --sec-command risk --workspace <workspace>
python scripts/memory_guardian.py quality-gate --qg-command status --workspace <workspace>
python scripts/memory_guardian.py l3-confirm --l3-command pending --workspace <workspace>
python scripts/memory_guardian.py pid-adaptive --pid-command status --workspace <workspace>
python scripts/memory_guardian.py case-grow --grow-command record-trigger --grow-id <case_id> --workspace <workspace>
python scripts/memory_guardian.py case-invalidate --inv-command review --workspace <workspace>
```

### File Sync

```bash
python scripts/memory_sync.py --dry-run --workspace <workspace>
python scripts/memory_sync.py --workspace <workspace>
```

### Standalone Modules

```bash
python scripts/dataflow_report.py --workspace <workspace>
python scripts/quality_gate.py status --workspace <workspace>
python scripts/l3_confirm.py pending --workspace <workspace>
python scripts/pid_adaptive.py history --workspace <workspace>
python scripts/case_invalidate.py check --dry-run --workspace <workspace>
```

## Typical Ops Workflows

### Daily Health Check

1. Run `status`
2. Run `run --dry-run` (includes sync → decay → compact → gate)
3. Check `dataflow_report.py`
4. If pending items exist, check `quality-gate`, `l3-confirm`, `case-invalidate`

### First-time Setup with Existing Workspace

1. Backup workspace
2. Run `bootstrap`
3. Run `migrate --dry-run`; pass `--no-backup` to skip backup
4. Run `migrate-bootstrap --dry-run`
5. Run `status` and `run --dry-run`

### Schema / Version Upgrade

1. Run `migrate-042 --dry-run` first
2. After confirming clean output, run `--apply` (mutually exclusive with `--dry-run`)
3. After upgrade, re-run `status`, `run --dry-run`, `dataflow_report.py`

## Unified CLI Notes

- `violations-health` View violation rule health summary directly without switching to sub-scripts.
- `security --sec-command risk` Without `--action`, lists risk levels for all known actions; with `--action <name>`, evaluates a single action.
- `case-grow --grow-command record-trigger` Requires `--grow-id <case_id>`.
- `migrate --no-backup` Explicitly disables v0.3 → v0.4 migration backup; `migrate-042` `--apply` and `--dry-run` are mutually exclusive.

## Testing & Verification

The repo primarily uses `unittest`. Core commands:

```bash
python -m unittest discover -s tests -p "test_*.py" -v
python tests/test_all.py
python scripts/memory_guardian.py status --workspace <workspace>
python scripts/memory_guardian.py run --dry-run --workspace <workspace>
python scripts/dataflow_report.py --workspace <workspace>
```

Current regression coverage includes:

- bootstrap case migration
- high-importance protection
- schema normalization
- telemetry / dataflow reports
- compaction / log rotation
- ingest service
- typed decay
- quality gate state
- snapshot / audit events
- CLI behavior
- File sync (memory_sync): extraction, dedup, ingest, timestamp

## Design Philosophy

- Keep `scripts/` as compatible entry points, no one-shot rewrite
- Fix data flow first, then abstract modules
- `meta.json` remains the default source of truth
- Prefer dry-run before all dangerous operations
- Verify modules actually process data via telemetry, not just code path existence

## Notes

- Do not commit real sensitive memory data, keys, or tokens
- Before `--apply`, confirm `OPENCLAW_WORKSPACE` matches `--workspace`
- This repo is designed around local file workspaces; most capabilities run without external services
- Even when only modifying docs, run `status` and related dry-runs to ensure example commands haven't drifted

## Related Documents

- [`CHANGELOG.md`](CHANGELOG.md)
- [`AGENTS.md`](AGENTS.md)
- [`docs/design-proposal.md` — Design proposal (v0.4.6)


path: tests/conftest.py
content: """Pytest conftest — add scripts/ and subpackages to sys.path."""

import os
import sys

# Add the project root so we can import scripts/* and mg_* subpackages
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(ROOT, "scripts")

for p in (ROOT, SCRIPTS):
    if p not in sys.path:
        sys.path.insert(0, p)


path: tests/test_case_invalidate.py
content: """Tests for case_invalidate — case invalidation lifecycle."""

import json
import os
import pytest

from case_invalidate import (
    check_invalidations,
    get_review_queue,
    get_status,
    _find_memory,
    _cap_frozen,
)


@pytest.fixture
def meta_path(tmp_path):
    p = str(tmp_path / "meta.json")
    os.makedirs(os.path.dirname(p), exist_ok=True)
    with open(p, "w") as f:
        json.dump({
            "version": "0.4.2",
            "memories": [],
            "conflicts": [],
            "security_rules": [],
            "entities": {},
            "invalidation_queue": [],
            "frozen_cases": [],
        }, f)
    return p


class TestFindMemory:
    def test_found(self):
        mem = {"id": "mem_1", "content": "test"}
        assert _find_memory({"memories": [mem]}, "mem_1") is mem

    def test_not_found(self):
        assert _find_memory({"memories": []}, "nonexistent") is None


class TestCheckInvalidations:
    def test_empty_queue(self, meta_path):
        result = check_invalidations(meta_path)
        assert isinstance(result, dict)


class TestGetReviewQueue:
    def test_empty(self, meta_path):
        queue = get_review_queue(meta_path)
        assert queue == []


class TestGetStatus:
    def test_empty(self, meta_path):
        result = get_status(meta_path)
        assert isinstance(result, dict)


class TestCapFrozen:
    def test_under_cap(self):
        meta = {
            "memories": [{"id": f"f_{i}", "status": "frozen", "frozen_at": f"2026-01-{i+1:02d}"} for i in range(3)]
        }
        _cap_frozen(meta)
        assert len([m for m in meta["memories"] if m["status"] == "frozen"]) == 3

    def test_over_cap(self):
        meta = {
            "memories": [{"id": f"f_{i}", "status": "frozen", "frozen_at": f"2026-01-{i+1:02d}"} for i in range(100)]
        }
        _cap_frozen(meta)
        frozen_count = len([m for m in meta["memories"] if m["status"] == "frozen"])
        assert frozen_count <= 50


path: tests/test_dataflow_report.py
content: """Tests for dataflow_report — memory dataflow analysis."""

import json
import pytest


class TestDataflowReport:
    def test_main_runs(self):
        """dataflow_report only has main(), verify it's callable."""
        from dataflow_report import main
        assert callable(main)


path: tests/test_ingest_service.py
content: """Tests for mg_app.ingest_service — application-layer ingest orchestration."""

import json
import os
import pytest

from mg_app.ingest_service import IngestService, _fill_fields, _detect_provenance, _evaluate_pool
from mg_repo.meta_json_repository import MetaJsonRepository


@pytest.fixture
def workspace(tmp_path):
    ws = str(tmp_path)
    meta_path = os.path.join(ws, "memory", "meta.json")
    os.makedirs(os.path.dirname(meta_path), exist_ok=True)
    # Initialize meta.json
    with open(meta_path, "w") as f:
        json.dump({
            "version": "0.4.2",
            "memories": [],
            "conflicts": [],
            "security_rules": [],
            "entities": {},
        }, f)
    return ws


@pytest.fixture
def repo(workspace):
    meta_path = os.path.join(workspace, "memory", "meta.json")
    return MetaJsonRepository(meta_path, workspace=workspace)


@pytest.fixture
def service(repo):
    return IngestService(repo)


class TestFillFields:
    def test_content_only(self):
        fields = _fill_fields(content="hello")
        assert fields["content"] == "hello"
        assert fields["is_case"] is False

    def test_case_fields(self):
        fields = _fill_fields(situation="sit", judgment="judg")
        assert fields["is_case"] is True
        assert "情境：sit" in fields["content"]


class TestDetectProvenance:
    def test_default(self):
        level, source = _detect_provenance("普通内容")
        assert level == "L2"

    def test_explicit(self):
        level, source = _detect_provenance("test", source="human")
        assert level == "L1"


class TestEvaluatePool:
    def test_empty_pool(self):
        result = _evaluate_pool("test content", [])
        assert result["duplicate"] is None
        assert result["active_count"] == 0

    def test_with_duplicate(self):
        mems = [{"id": "mem_1", "content": "exact match content here", "status": "active"}]
        result = _evaluate_pool("exact match content here", mems)
        assert result["duplicate"] is not None

    def test_active_count(self):
        mems = [
            {"id": "mem_1", "content": "a b c", "status": "active"},
            {"id": "mem_2", "content": "d e f", "status": "archived"},
            {"id": "mem_3", "content": "g h i", "status": "observing"},
        ]
        result = _evaluate_pool("test", mems)
        assert result["active_count"] == 2


class TestIngestService:
    def test_create_basic_memory(self, service, workspace):
        result = service.ingest(
            content="test memory content",
            importance=0.5,
            tags=["test"],
            workspace=workspace,
        )
        assert result["action"] == "created"
        assert result["id"].startswith("mem_")

    def test_create_case(self, service, workspace):
        result = service.ingest(
            content=None,
            importance=0.7,
            tags=["case_tag"],
            workspace=workspace,
            situation="当发生X时",
            judgment="应该做Y",
        )
        assert result["action"] == "created"
        mem = result["memory"]
        assert mem["situation"] == "当发生X时"
        assert mem["judgment"] == "应该做Y"

    def test_dedup_prevents_duplicate(self, service, workspace):
        service.ingest(content="unique content xyz", importance=0.5, tags=[], workspace=workspace)
        result = service.ingest(content="unique content xyz", importance=0.5, tags=[], workspace=workspace)
        assert result["action"] == "dedup_found"

    def test_update_existing(self, service, workspace):
        create = service.ingest(content="original", importance=0.5, tags=[], workspace=workspace)
        mem_id = create["id"]
        result = service.ingest(
            content="updated",
            importance=0.8,
            tags=["new_tag"],
            workspace=workspace,
            update_id=mem_id,
        )
        assert result["action"] == "updated"

    def test_update_not_found(self, service, workspace):
        result = service.ingest(
            content="test",
            importance=0.5,
            tags=[],
            workspace=workspace,
            update_id="nonexistent_id",
        )
        assert result["action"] == "not_found"

    def test_no_content_error(self, service, workspace):
        result = service.ingest(
            content=None,
            importance=0.5,
            tags=[],
            workspace=workspace,
        )
        assert result["action"] == "error"

    def test_classification_applied(self, service, workspace):
        result = service.ingest(
            content="memory-guardian v0.4.5 项目开发",
            importance=0.6,
            tags=["项目"],
            workspace=workspace,
        )
        assert result["action"] == "created"
        classification = result.get("classification")
        assert classification is not None
        assert classification["primary_tag"] == "project"

    def test_provenance_assigned(self, service, workspace):
        result = service.ingest(
            content="记住这个偏好设置",
            importance=0.5,
            tags=[],
            workspace=workspace,
        )
        mem = result["memory"]
        assert mem["provenance_level"] == "L1"

    def test_memory_file_written(self, service, workspace):
        result = service.ingest(
            content="test file write",
            importance=0.5,
            tags=["test"],
            workspace=workspace,
        )
        assert result["written_file"] is not None
        assert os.path.exists(result["written_file"])

    def test_blocked_by_security(self, service, workspace):
        # Add a security rule
        meta = service.repo.load_meta()
        meta["security_rules"] = [{
            "id": "test-block",
            "pattern": r"BLOCKED_CONTENT",
            "description": "test block",
            "severity": "critical",
        }]
        service.repo.save_meta(meta)
        result = service.ingest(
            content="This has BLOCKED_CONTENT in it",
            importance=0.5,
            tags=[],
            workspace=workspace,
        )
        assert result["action"] == "blocked"


path: tests/test_l3_confirm.py
content: """Tests for l3_confirm — L3 human confirmation flow."""

import json
import os
import pytest
from datetime import datetime

from l3_confirm import (
    create_confirmation,
    get_pending,
    get_status,
    _deadline_iso,
)


@pytest.fixture
def meta_path(tmp_path):
    p = str(tmp_path / "meta.json")
    os.makedirs(os.path.dirname(p), exist_ok=True)
    with open(p, "w") as f:
        json.dump({
            "version": "0.4.2",
            "memories": [],
            "conflicts": [],
            "security_rules": [],
            "entities": [],
            "l3_confirmations": [],
        }, f)
    return p


class TestCreateConfirmation:
    def test_creates_entry(self, meta_path):
        result = create_confirmation(meta_path, "ingest", {"content": "test"}, "test reason")
        assert result["status"] == "pending"
        assert result["action"] == "ingest"
        assert "sla_deadline" in result

    def test_persists(self, meta_path):
        create_confirmation(meta_path, "ingest", {"content": "test"})
        pending = get_pending(meta_path)
        assert len(pending) == 1


class TestGetPending:
    def test_empty(self, meta_path):
        assert get_pending(meta_path) == []

    def test_with_pending(self, meta_path):
        create_confirmation(meta_path, "ingest", {"content": "test"})
        pending = get_pending(meta_path)
        assert len(pending) == 1
        assert pending[0]["status"] == "pending"


class TestGetStatus:
    def test_empty(self, meta_path):
        status = get_status(meta_path)
        assert isinstance(status, dict)
        assert "total" in status
        assert status["total"] == 0


class TestDeadlineIso:
    def test_future_deadline(self):
        deadline = _deadline_iso(24)
        dt = datetime.fromisoformat(deadline)
        from mg_utils import CST
        assert dt > datetime.now(CST)

    def test_past_deadline(self):
        deadline = _deadline_iso(-1)
        dt = datetime.fromisoformat(deadline)
        from mg_utils import CST
        assert dt < datetime.now(CST)


path: tests/test_mcp_server.py
content: """Tests for mcp_server — MCP tool registration."""

import os
import pytest


class TestMCPModule:
    def test_importable(self):
        """MCP server module should be importable."""
        import mcp_server
        assert hasattr(mcp_server, "MemoryGuardianServer")

    def test_server_class_has_handlers(self):
        """Server class should have tool handler methods."""
        import mcp_server
        server = mcp_server.MemoryGuardianServer(".")
        assert hasattr(server, "handle_memory_status")
        assert hasattr(server, "handle_memory_query")


path: tests/test_memory_case_grow.py
content: """Tests for memory_case_grow — case growth engine."""

import pytest

from memory_case_grow import (
    SceneGroup,
    mark_authority_context,
    schedule_post_stress_review,
    apply_tag_penalty,
    add_context_burr,
    get_context_burrs,
    consequence_direction,
    add_failure_condition,
    adjust_confidence,
    jaccard,
    tag_jaccard,
    _default_thresholds,
)


class TestSceneGroup:
    def test_create(self):
        sg = SceneGroup(group_id="test_group", tag_prefix="project")
        assert sg.group_id == "test_group"

    def test_has_update(self):
        sg = SceneGroup(group_id="test")
        assert hasattr(sg, "update")


class TestMarkAuthorityContext:
    def test_no_context(self):
        mem = {"content": "test", "tags": [], "confidence": 0.5}
        result = mark_authority_context(mem)
        assert isinstance(result, bool)


class TestSchedulePostStressReview:
    def test_returns_none(self):
        mem = {"id": "case_1", "confidence": 0.5}
        result = schedule_post_stress_review(mem)
        assert result is None  # Only schedules if conditions met


class TestApplyTagPenalty:
    def test_create_action_no_penalty(self):
        conf = apply_tag_penalty(0.8, "create")
        # May or may not penalize depending on implementation
        assert isinstance(conf, float)


class TestContextBurrs:
    def test_add_and_get_valid_type(self):
        mem = {"id": "case_1"}
        add_context_burr(mem, "situation_context", {"data": 1})
        burrs = get_context_burrs(mem, "situation_context")
        assert len(burrs) == 1

    def test_no_burrs(self):
        burrs = get_context_burrs({}, "nonexistent")
        assert burrs == []

    def test_invalid_type_ignored(self):
        mem = {"id": "case_1"}
        add_context_burr(mem, "invalid_type", {"data": 1})
        burrs = get_context_burrs(mem, "invalid_type")
        assert burrs == []


class TestConsequenceDirection:
    def test_positive(self):
        assert consequence_direction("成功解决了问题") == "positive"

    def test_negative(self):
        assert consequence_direction("失败导致崩溃") == "negative"

    def test_neutral(self):
        assert consequence_direction("无影响") == "neutral"


class TestAddFailureCondition:
    def test_adds_condition(self):
        mem = {"failure_conditions": []}
        add_failure_condition(mem, "trigger_test", "some context")
        assert len(mem["failure_conditions"]) == 1

    def test_updates_existing(self):
        mem = {"failure_conditions": [{"intent": "trigger_test", "count": 1}]}
        add_failure_condition(mem, "trigger_test")
        assert mem["failure_conditions"][0]["count"] == 2


class TestAdjustConfidence:
    def test_consistent_boosts(self):
        mem = {"confidence": 0.5}
        adjust_confidence(mem, consistent=True)
        assert mem["confidence"] > 0.5

    def test_inconsistent_reduces(self):
        mem = {"confidence": 0.8}
        adjust_confidence(mem, consistent=False)
        assert mem["confidence"] < 0.8


class TestJaccardHelpers:
    def test_jaccard_identical(self):
        # jaccard returns distance (1 = identical)
        assert jaccard({"a", "b"}, {"a", "b"}) == 1.0

    def test_jaccard_disjoint(self):
        assert jaccard({"a"}, {"b"}) == 0.0

    def test_tag_jaccard_identical(self):
        assert tag_jaccard(["a", "b"], ["a", "b"]) == 1.0

    def test_tag_jaccard_disjoint(self):
        assert tag_jaccard(["a"], ["b"]) == 0.0


class TestDefaultThresholds:
    def test_has_defaults(self):
        thresholds = _default_thresholds()
        assert isinstance(thresholds, dict)
        assert len(thresholds) > 0


# ─── New tests for uncovered functions ───────────────────────

import json

from memory_case_grow import (
    build_scene_groups,
    record_trigger,
    rescan_authority_context,
    prune_context_burrs,
    get_adaptive_threshold,
    run_post_stress_reviews,
)


def _write_meta(tmp_path, meta):
    """Helper: write meta dict to tmp_path/meta.json."""
    p = tmp_path / "meta.json"
    p.write_text(json.dumps(meta, ensure_ascii=False))
    return str(p)


class TestBuildSceneGroups:
    def test_empty_meta(self):
        groups = build_scene_groups({"memories": []})
        assert groups == {}

    def test_groups_by_first_tag(self):
        meta = {
            "memories": [
                {"id": "c1", "status": "active", "case_type": "case", "tags": ["project:alpha"]},
                {"id": "c2", "status": "active", "case_type": "case", "tags": ["project:beta"]},
                {"id": "c3", "status": "active", "case_type": "case", "tags": ["project:alpha"]},
            ]
        }
        groups = build_scene_groups(meta)
        assert "tag:project" in groups
        assert groups["tag:project"].N == 3

    def test_skips_non_case(self):
        meta = {
            "memories": [
                {"id": "m1", "status": "active", "case_type": "memory", "tags": ["project"]},
                {"id": "c1", "status": "active", "case_type": "case", "tags": ["project"]},
            ]
        }
        groups = build_scene_groups(meta)
        assert groups["tag:project"].N == 1

    def test_untagged_goes_to_default(self):
        meta = {
            "memories": [
                {"id": "c1", "status": "active", "case_type": "case", "tags": []},
            ]
        }
        groups = build_scene_groups(meta)
        assert "_default" in groups


class TestRecordTrigger:
    def test_increment_trigger(self, tmp_path):
        meta = {"memories": [{"id": "case_1", "trigger_count": 2, "access_count": 1}]}
        p = _write_meta(tmp_path, meta)
        result = record_trigger(p, "case_1")
        assert result["updated"] is True
        assert result["trigger_count"] == 3

    def test_not_found(self, tmp_path):
        meta = {"memories": []}
        p = _write_meta(tmp_path, meta)
        result = record_trigger(p, "case_nonexistent")
        assert result["updated"] is False

    def test_updates_access_count(self, tmp_path):
        meta = {"memories": [{"id": "case_1", "trigger_count": 0, "access_count": 5}]}
        p = _write_meta(tmp_path, meta)
        record_trigger(p, "case_1")
        # Re-read and verify
        from mg_utils import load_meta
        updated = load_meta(p)
        assert updated["memories"][0]["access_count"] == 6


class TestRescanAuthorityContext:
    def test_flags_authority_case(self, tmp_path):
        meta = {
            "memories": [{
                "id": "case_1", "status": "active",
                "provenance_level": "L1",
                "situation": "老板要求审批",
                "tags": [],
            }]
        }
        p = _write_meta(tmp_path, meta)
        result = rescan_authority_context(p)
        assert result["newly_flagged"] == 1
        assert result["total_flagged"] == 1

    def test_no_new_flags(self, tmp_path):
        meta = {
            "memories": [{
                "id": "case_1", "status": "active",
                "provenance_level": "L2",  # not L1
                "tags": ["管理"],
            }]
        }
        p = _write_meta(tmp_path, meta)
        result = rescan_authority_context(p)
        assert result["newly_flagged"] == 0

    def test_skips_archived(self, tmp_path):
        meta = {
            "memories": [{
                "id": "case_1", "status": "archived",
                "provenance_level": "L1",
                "situation": "老板审批",
            }]
        }
        p = _write_meta(tmp_path, meta)
        result = rescan_authority_context(p)
        assert result["newly_flagged"] == 0


class TestPruneContextBurrs:
    def test_prunes_old_burrs(self, tmp_path):
        from datetime import datetime, timedelta
        from mg_utils import CST
        old_ts = (datetime.now(CST) - timedelta(days=100)).isoformat()
        meta = {
            "memories": [{
                "id": "case_1",
                "context_burrs": {
                    "situation_context": [{"data": "old", "added_at": old_ts}],
                }
            }]
        }
        p = _write_meta(tmp_path, meta)
        pruned = prune_context_burrs(p, max_age_days=90)
        assert pruned >= 1

    def test_keeps_recent_burrs(self, tmp_path):
        meta = {
            "memories": [{
                "id": "case_1",
                "context_burrs": {
                    "failure_case": [{"data": "recent", "added_at": "2099-01-01T00:00:00+08:00"}],
                }
            }]
        }
        p = _write_meta(tmp_path, meta)
        pruned = prune_context_burrs(p, max_age_days=90)
        assert pruned == 0

    def test_no_burrs_noop(self, tmp_path):
        meta = {"memories": [{"id": "case_1"}]}
        p = _write_meta(tmp_path, meta)
        pruned = prune_context_burrs(p, max_age_days=90)
        assert pruned == 0


class TestGetAdaptiveThreshold:
    def test_returns_defaults_for_unknown_tag(self):
        thresholds = get_adaptive_threshold({}, "unknown:tag")
        assert thresholds["absorb"] == 0.8
        assert thresholds["source"] == "fixed_default"

    def test_returns_defaults_for_empty_groups(self):
        thresholds = get_adaptive_threshold({}, "")
        assert isinstance(thresholds, dict)
        assert "absorb" in thresholds

    def test_returns_adaptive_for_large_group(self):
        groups = {"tag:project": SceneGroup("tag:project", "project")}
        # Simulate N >= 20
        groups["tag:project"].case_ids = [f"c{i}" for i in range(25)]
        groups["tag:project"].update(groups["tag:project"].case_ids)
        thresholds = get_adaptive_threshold(groups, "project:feature")
        assert "absorb" in thresholds
        # For N 20-50, absorb should be > 0.8
        assert thresholds["absorb"] >= 0.8


class TestRunPostStressReviews:
    def test_finds_due_review(self):
        from datetime import datetime, timedelta, timezone
        past = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
        meta = {
            "memories": [{
                "id": "case_1",
                "authority_context": True,
                "review_at_pressure_free": past,
                "post_stress_review_status": "pending",
                "content": "test case for authority review",
                "confidence": 0.7,
            }]
        }
        result = run_post_stress_reviews(meta, dry_run=True)
        assert result["stats"]["due_count"] == 1
        assert len(result["due"]) == 1

    def test_skips_resolved(self):
        meta = {
            "memories": [{
                "id": "case_1",
                "authority_context": True,
                "review_at_pressure_free": "2020-01-01T00:00:00+00:00",
                "post_stress_review_status": "resolved",
            }]
        }
        result = run_post_stress_reviews(meta, dry_run=True)
        assert result["stats"]["due_count"] == 0

    def test_marks_pending_as_needs_review(self):
        from datetime import datetime, timedelta, timezone
        past = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
        meta = {
            "memories": [{
                "id": "case_1",
                "authority_context": True,
                "review_at_pressure_free": past,
                "post_stress_review_status": "pending",
                "content": "test",
                "confidence": 0.5,
            }]
        }
        result = run_post_stress_reviews(meta, dry_run=False)
        assert result["stats"]["reviewed_count"] == 1
        # The case should now have needs_review status
        assert meta["memories"][0]["post_stress_review_status"] == "needs_review"


path: tests/test_memory_case_rollback.py
content: """Tests for memory_case_rollback — case versioning and rollback."""

import json
import os
import pytest
from datetime import datetime, timedelta

from memory_case_rollback import (
    _days_since,
    _find_case,
    _find_all_versions,
    create_version_entry,
    compute_failure_signals,
)


class TestDaysSince:
    def test_recent(self):
        from mg_utils import CST
        ts = (datetime.now(CST) - timedelta(hours=1)).isoformat()
        days = _days_since(ts)
        assert days < 1

    def test_old(self):
        from mg_utils import CST
        ts = (datetime.now(CST) - timedelta(days=10)).isoformat()
        days = _days_since(ts)
        assert 9 <= days <= 11

    def test_none(self):
        assert _days_since(None) == float('inf')

    def test_naive_raises(self):
        # Naive datetime without timezone → inf
        ts = "2026-04-06T12:00:00"
        assert _days_since(ts) == float('inf')


class TestFindCase:
    def test_found(self):
        case = {"id": "case_1", "situation": "test"}
        meta = {"memories": [case]}
        assert _find_case(meta, "case_1") is case

    def test_not_found(self):
        assert _find_case({"memories": []}, "nonexistent") is None


class TestFindAllVersions:
    def test_finds_versions(self):
        case = {"id": "case_1", "situation": "original"}
        v1 = {"id": "case_1_v1", "case_id": "case_1", "version": 1}
        meta = {"memories": [case, v1]}
        versions = _find_all_versions(meta, "case_1")
        assert len(versions) >= 1


class TestComputeFailureSignals:
    def test_returns_dict(self):
        mem = {"id": "case_1", "failure_conditions": []}
        signals = compute_failure_signals({"memories": [mem]}, "case_1")
        assert isinstance(signals, dict)
        assert "failure_rate" in signals

    def test_with_failures(self):
        mem = {"id": "case_1", "failure_conditions": [{"intent": "test", "count": 3}]}
        signals = compute_failure_signals({"memories": [mem]}, "case_1")
        assert isinstance(signals, dict)


# ─── New tests for uncovered functions ───────────────────────

from memory_case_rollback import (
    rollback_case,
    sweep_deprecated,
)


class TestRollbackCase:
    def test_rollback_success(self):
        v1 = {"id": "case_v1", "version": 1, "deprecated": True,
               "version_group": "grp_1", "status": "archived"}
        v2 = {"id": "case_v2", "version": 2, "deprecated": False,
               "prev_version": "case_v1", "version_group": "grp_1", "status": "active"}
        meta = {"memories": [v1, v2]}
        success, msg, chain = rollback_case(meta, "case_v2", reason="test reason")
        assert success is True
        assert v2["deprecated"] is True
        assert v1["deprecated"] is False
        assert len(chain) == 2

    def test_rollback_no_prev_version(self):
        v1 = {"id": "case_v1", "version": 1, "deprecated": False, "status": "active"}
        meta = {"memories": [v1]}
        success, msg, chain = rollback_case(meta, "case_v1")
        assert success is False
        assert "No previous version" in msg

    def test_rollback_not_found(self):
        meta = {"memories": []}
        success, msg, chain = rollback_case(meta, "nonexistent")
        assert success is False
        assert "not found" in msg

    def test_rollback_records_version_log(self):
        v1 = {"id": "case_v1", "version": 1, "deprecated": True,
               "version_group": "grp_1"}
        v2 = {"id": "case_v2", "version": 2, "deprecated": False,
               "prev_version": "case_v1", "version_group": "grp_1"}
        meta = {"memories": [v1, v2]}
        rollback_case(meta, "case_v2", reason="revert")
        assert "version_log" in v1
        assert v1["version_log"][0]["action"] == "rollback"


class TestSweepDeprecated:
    def test_sweeps_old_deprecated(self, tmp_path):
        import json
        from datetime import datetime, timedelta
        from mg_utils import CST
        old_ts = (datetime.now(CST) - timedelta(days=35)).isoformat()
        meta = {
            "memories": [{
                "id": "case_1", "deprecated": True,
                "deprecated_at": old_ts, "version": 1,
            }]
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        archived = sweep_deprecated(meta, dry_run=False, meta_path=p)
        assert len(archived) == 1
        assert meta["memories"][0]["status"] == "archived"

    def test_keeps_recent_deprecated(self, tmp_path):
        import json
        from datetime import datetime, timedelta
        from mg_utils import CST
        recent_ts = (datetime.now(CST) - timedelta(days=5)).isoformat()
        meta = {
            "memories": [{
                "id": "case_1", "deprecated": True,
                "deprecated_at": recent_ts, "version": 1,
            }]
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        archived = sweep_deprecated(meta, dry_run=False, meta_path=p)
        assert len(archived) == 0

    def test_dry_run_no_write(self, tmp_path):
        import json
        from datetime import datetime, timedelta
        from mg_utils import CST
        old_ts = (datetime.now(CST) - timedelta(days=100)).isoformat()
        meta = {
            "memories": [{
                "id": "case_1", "deprecated": True,
                "deprecated_at": old_ts, "version": 2,
            }]
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        archived = sweep_deprecated(meta, dry_run=True)
        assert len(archived) == 1
        # Status should NOT be changed in dry_run
        assert meta["memories"][0].get("status") != "archived"

    def test_no_deprecated_cases(self):
        meta = {"memories": [{"id": "case_1", "deprecated": False}]}
        archived = sweep_deprecated(meta, dry_run=False)
        assert len(archived) == 0


path: tests/test_memory_compact.py
content: """Tests for memory_compact — redundancy detection and compaction."""

import os
import pytest
from memory_compact import (
    jaccard,
    load_file,
    estimate_tokens,
    section_token_analysis,
    detect_redundancy,
    detect_internal_redundancy,
    detect_bloat_sections,
    aggressive_compact_memory_text,
)


class TestJaccard:
    def test_identical(self):
        assert jaccard({"a", "b"}, {"a", "b"}) == 0.0

    def test_disjoint(self):
        assert jaccard({"a"}, {"b"}) == 1.0

    def test_partial(self):
        d = jaccard({"a", "b"}, {"a", "c"})
        assert 0 < d < 1


class TestLoadFile:
    def test_existing(self, tmp_path):
        p = tmp_path / "test.txt"
        p.write_text("hello")
        assert load_file(str(p)) == "hello"

    def test_missing(self):
        assert load_file("/nonexistent/path.txt") == ""


class TestEstimateTokens:
    def test_cjk(self):
        assert estimate_tokens("你好世界") > 0

    def test_english(self):
        assert estimate_tokens("hello world") > 0

    def test_empty(self):
        assert estimate_tokens("") == 0


class TestSectionTokenAnalysis:
    def test_basic_sections(self):
        text = "# Header\nline1\n## Section\nline2\n"
        sections = section_token_analysis(text)
        assert len(sections) >= 1

    def test_empty_text(self):
        sections = section_token_analysis("")
        assert sections == []

    def test_no_headers(self):
        sections = section_token_analysis("just plain text\nno headers")
        assert len(sections) == 1


class TestDetectRedundancy:
    def test_no_redundancy(self):
        issues = detect_redundancy("completely unique content A", {
            "day1.md": "totally different content B"
        })
        assert len(issues) == 0

    def test_high_redundancy(self):
        memory = "记忆系统设计包括衰减引擎和质量门控组件"
        daily = "### 记忆系统设计\n记忆系统设计包括衰减引擎和质量门控组件\n额外内容"
        issues = detect_redundancy(memory, {"day1.md": daily})
        assert len(issues) > 0

    def test_empty_inputs(self):
        issues = detect_redundancy("", {})
        assert len(issues) == 0


class TestDetectInternalRedundancy:
    def test_no_redundancy(self):
        issues = detect_internal_redundancy("line one\nline two\nline three")
        assert len(issues) == 0

    def test_duplicated_sections(self):
        text = "## Section A\nrepeated content here\n## Section B\nrepeated content here"
        issues = detect_internal_redundancy(text)
        # May or may not flag depending on token overlap threshold
        assert isinstance(issues, list)


class TestDetectBloatSections:
    def test_small_sections_ok(self):
        issues = detect_bloat_sections("# Small\nshort content")
        assert len(issues) == 0

    def test_large_section_flagged(self):
        big_section = "# Big\n" + "word " * 600
        issues = detect_bloat_sections(big_section, max_tokens_per_section=50)
        assert len(issues) > 0


class TestAggressiveCompact:
    def test_removes_redundant_sections(self):
        text = "## 项目\nmemory-guardian v0.4.5\n## 项目\nmemory-guardian v0.4.5\n## 其他\nunique"
        result = aggressive_compact_memory_text(text)
        # Should have reduced content
        assert isinstance(result, str)

    def test_empty_input(self):
        assert aggressive_compact_memory_text("") == ""


path: tests/test_memory_decay.py
content: """Tests for memory_decay — five-track Bayesian decay engine."""

import math
from datetime import datetime, timedelta
import pytest

from mg_utils import CST, _now_iso
from memory_decay import (
    record_signal,
    compute_signal_boost,
    compute_reactivation_multiplier,
    compute_importance_factor,
    compute_network_factor,
    compute_context_factor,
    compute_beta_recovery,
    compute_decay,
    check_ttl,
    IMPORTANCE_WEIGHT,
    NETWORK_WEIGHT,
    CONTEXT_WEIGHT,
    BETA_ARCHIVE_MARK,
    ACCESS_SIGNALS_MAX,
    SIGNAL_WEIGHTS,
)


def _make_mem(**overrides):
    now = _now_iso()
    defaults = {
        "id": "mem_test",
        "content": "test memory content",
        "importance": 0.5,
        "created_at": now,
        "last_accessed": now,
        "access_count": 0,
        "trigger_count": 0,
        "beta": 1.0,
        "decay_score": 0.5,
        "status": "active",
        "cost_factors": {},
        "confidence": 0.5,
        "failure_conditions": [],
        "access_signals": [],
        "reactivation_count": 0,
        "memory_type": "derive",
    }
    defaults.update(overrides)
    return defaults


def _recent_now():
    return datetime.now(CST)


class TestRecordSignal:
    def test_search_hit_increments_reactivation(self):
        mem = _make_mem()
        result = record_signal(mem, "search_hit")
        assert result["reactivation_count"] == 1
        assert mem["reactivation_count"] == 1
        assert mem["signal_level"] == "search_hit"

    def test_file_loaded_no_reactivation(self):
        mem = _make_mem()
        result = record_signal(mem, "file_loaded")
        assert result["reactivation_count"] == 0

    def test_rolling_buffer_capped(self):
        mem = _make_mem()
        for i in range(ACCESS_SIGNALS_MAX + 10):
            record_signal(mem, "file_loaded")
        assert len(mem["access_signals"]) == ACCESS_SIGNALS_MAX

    def test_session_id_recorded(self):
        mem = _make_mem()
        record_signal(mem, "search_hit", session_id="session_123")
        assert mem["access_signals"][0]["session_id"] == "session_123"

    def test_unknown_signal_type(self):
        mem = _make_mem()
        result = record_signal(mem, "unknown_type")
        assert result["signal_type"] == "unknown_type"


class TestComputeSignalBoost:
    def test_no_signals_zero_boost(self):
        mem = _make_mem()
        assert compute_signal_boost(mem, _recent_now()) == 0.0

    def test_recent_signal_boosts(self):
        mem = _make_mem()
        record_signal(mem, "file_loaded")  # weight=1.0
        boost = compute_signal_boost(mem, _recent_now())
        assert boost > 0

    def test_old_signal_no_boost(self):
        mem = _make_mem()
        # Inject an old signal (30 days ago)
        old_ts = (_recent_now() - timedelta(days=30)).isoformat()
        mem["access_signals"] = [{"type": "file_loaded", "at": old_ts}]
        boost = compute_signal_boost(mem, _recent_now())
        assert boost == 0.0

    def test_boost_capped_at_0_3(self):
        mem = _make_mem()
        for _ in range(20):
            record_signal(mem, "content_modified")  # weight=1.5
        boost = compute_signal_boost(mem, _recent_now())
        assert boost <= 0.3

    def test_search_hit_half_weight(self):
        mem = _make_mem()
        record_signal(mem, "search_hit")  # weight=0.5
        boost = compute_signal_boost(mem, _recent_now())
        assert 0 < boost < 0.1


class TestComputeReactivationMultiplier:
    def test_zero_reactivation(self):
        assert compute_reactivation_multiplier(_make_mem()) == 1.0

    def test_high_reactivation_slower_decay(self):
        mem = _make_mem(reactivation_count=10)
        mult = compute_reactivation_multiplier(mem)
        assert mult < 1.0

    def test_floor_respected(self):
        from memory_decay import REACTIVATION_MIN_MULTIPLIER
        mem = _make_mem(reactivation_count=1000)
        mult = compute_reactivation_multiplier(mem)
        assert mult >= REACTIVATION_MIN_MULTIPLIER


class TestComputeImportanceFactor:
    def test_base_importance(self):
        mem = _make_mem(importance=0.7)
        assert compute_importance_factor(mem) == pytest.approx(0.7, abs=0.01)

    def test_cost_modifier(self):
        mem = _make_mem(importance=0.5, cost_factors={"human_cost": 2, "transfer_cost": 1})
        factor = compute_importance_factor(mem)
        assert factor > 0.5

    def test_capped_at_1(self):
        mem = _make_mem(importance=0.9, cost_factors={"human_cost": 3, "transfer_cost": 3})
        assert compute_importance_factor(mem) <= 1.0

    def test_missing_importance_uses_usage(self):
        mem = _make_mem(importance=0, trigger_count=10)
        factor = compute_importance_factor(mem)
        assert factor > 0


class TestComputeNetworkFactor:
    def test_min_floor(self):
        mem = _make_mem()
        factor, _ = compute_network_factor(mem, _recent_now())
        assert factor >= 0.3

    def test_access_increases(self):
        mem = _make_mem(access_count=50)
        factor, _ = compute_network_factor(mem, _recent_now())
        assert factor > 0.3

    def test_trigger_bonus(self):
        mem_low = _make_mem(trigger_count=0)
        mem_high = _make_mem(trigger_count=20)
        f_low, _ = compute_network_factor(mem_low, _recent_now())
        f_high, _ = compute_network_factor(mem_high, _recent_now())
        assert f_high >= f_low

    def test_action_conclusion_bonus(self):
        mem_no = _make_mem()
        mem_yes = _make_mem(action_conclusion="do something")
        f_no, _ = compute_network_factor(mem_no, _recent_now())
        f_yes, _ = compute_network_factor(mem_yes, _recent_now())
        assert f_yes >= f_no

    def test_cooling_caps(self):
        future = (_recent_now() + timedelta(hours=1)).isoformat()
        mem = _make_mem(cooldown_active=True, cooldown_until=future)
        factor, _ = compute_network_factor(mem, _recent_now())
        assert factor <= 0.3

    def test_expired_cooling_ignores(self):
        past = (_recent_now() - timedelta(hours=1)).isoformat()
        mem = _make_mem(cooldown_active=True, cooldown_until=past)
        factor, cooldown = compute_network_factor(mem, _recent_now())
        assert cooldown is False


class TestComputeContextFactor:
    def test_recent_content_high(self):
        mem = _make_mem(created_at=_now_iso(), last_accessed=_now_iso())
        factor = compute_context_factor(mem, 0.001, _recent_now())
        assert factor > 0.5

    def test_old_content_low(self):
        old = (_recent_now() - timedelta(days=200)).isoformat()
        mem = _make_mem(created_at=old, last_accessed=old)
        factor = compute_context_factor(mem, 0.01, _recent_now())
        assert factor < 0.3

    def test_access_boost(self):
        mem = _make_mem(last_accessed=_now_iso())
        factor = compute_context_factor(mem, 0.01, _recent_now())
        assert factor > 0.5

    def test_high_lambda_faster_decay(self):
        mem = _make_mem()
        f_low = compute_context_factor(mem, 0.001, _recent_now())
        f_high = compute_context_factor(mem, 0.1, _recent_now())
        assert f_low >= f_high

    def test_capped_at_1(self):
        mem = _make_mem(created_at=_now_iso(), last_accessed=_now_iso(), importance=1.0)
        factor = compute_context_factor(mem, 0.0, _recent_now())
        assert factor <= 1.0


class TestComputeBetaRecovery:
    def test_recent_access_beta_1(self):
        mem = _make_mem(last_accessed=_now_iso())
        beta, archive_mark, zombie = compute_beta_recovery(mem, _recent_now())
        assert beta == pytest.approx(1.0, abs=0.01)
        assert archive_mark is False

    def test_idle_increases_beta(self):
        old = (_recent_now() - timedelta(days=100)).isoformat()
        mem = _make_mem(created_at=old, last_accessed=old)
        beta, _, _ = compute_beta_recovery(mem, _recent_now())
        assert beta > 1.0

    def test_failure_count_increases_beta(self):
        old = (_recent_now() - timedelta(days=50)).isoformat()
        mem = _make_mem(created_at=old, last_accessed=old, failure_count=5)
        beta_fail, _, _ = compute_beta_recovery(mem, _recent_now())
        mem2 = _make_mem(created_at=old, last_accessed=old, failure_count=0)
        beta_clean, _, _ = compute_beta_recovery(mem2, _recent_now())
        assert beta_fail > beta_clean

    def test_archive_mark(self):
        old = (_recent_now() - timedelta(days=500)).isoformat()
        mem = _make_mem(created_at=old, last_accessed=old, failure_count=10)
        beta, archive_mark, _ = compute_beta_recovery(mem, _recent_now())
        if beta >= BETA_ARCHIVE_MARK:
            assert archive_mark is True

    def test_static_type_gentle_decay(self):
        old = (_recent_now() - timedelta(days=100)).isoformat()
        mem_static = _make_mem(created_at=old, last_accessed=old, memory_type="static")
        mem_derive = _make_mem(created_at=old, last_accessed=old, memory_type="derive")
        beta_static, _, _ = compute_beta_recovery(mem_static, _recent_now())
        beta_derive, _, _ = compute_beta_recovery(mem_derive, _recent_now())
        assert beta_static <= beta_derive

    def test_reactivation_slows_beta(self):
        old = (_recent_now() - timedelta(days=100)).isoformat()
        mem_no_react = _make_mem(created_at=old, last_accessed=old, reactivation_count=0)
        mem_with_react = _make_mem(created_at=old, last_accessed=old, reactivation_count=5)
        beta_no, _, _ = compute_beta_recovery(mem_no_react, _recent_now())
        beta_yes, _, _ = compute_beta_recovery(mem_with_react, _recent_now())
        assert beta_yes <= beta_no

    def test_zombie_detection(self):
        old = (_recent_now() - timedelta(days=60)).isoformat()
        mem = _make_mem(created_at=old, last_accessed=old, passive_wakeup_streak=0)
        beta, _, zombie = compute_beta_recovery(mem, _recent_now())
        assert zombie is True

    def test_minimum_floor(self):
        mem = _make_mem()
        beta, _, _ = compute_beta_recovery(mem, _recent_now())
        assert beta >= 0.1


class TestCheckTTL:
    def test_no_case_origin(self):
        expired, remaining = check_ttl(_make_mem(), _recent_now())
        assert expired is False

    def test_within_ttl(self):
        mem = _make_mem(case_origin="absorb", ttl_start_time=_now_iso())
        expired, remaining = check_ttl(mem, _recent_now())
        assert expired is False

    def test_expired_ttl(self):
        old = (_recent_now() - timedelta(days=365)).isoformat()
        mem = _make_mem(case_origin="absorb", ttl_start_time=old)
        expired, remaining = check_ttl(mem, _recent_now())
        assert expired is True

    def test_grace_extension(self):
        old_start = (_recent_now() - timedelta(days=29)).isoformat()
        recent_trigger = (_recent_now() - timedelta(days=2)).isoformat()
        mem = _make_mem(case_origin="absorb", ttl_start_time=old_start, last_triggered=recent_trigger)
        expired, remaining = check_ttl(mem, _recent_now())
        assert expired is False  # Grace extension


class TestComputeDecay:
    def test_returns_dict(self):
        mem = _make_mem()
        result = compute_decay(mem, 0.01, _recent_now())
        assert isinstance(result, dict)
        assert "new_score" in result
        assert "cooldown_active" in result
        assert "archive_mark" in result
        assert "is_zombie" in result
        assert "beta_recovery" in result
        assert "importance_factor" in result
        assert "network_factor" in result
        assert "context_factor" in result
        assert len(result) == 8

    def test_final_score_in_range(self):
        mem = _make_mem()
        result = compute_decay(mem, 0.01, _recent_now())
        assert 0.0 <= result["new_score"] <= 1.0

    def test_recent_memory_high_score(self):
        mem = _make_mem(created_at=_now_iso(), last_accessed=_now_iso(), importance=0.9)
        result = compute_decay(mem, 0.01, _recent_now())
        assert result["new_score"] > 0.5

    def test_old_low_importance_low_score(self):
        old = (_recent_now() - timedelta(days=365)).isoformat()
        mem = _make_mem(created_at=old, last_accessed=old, importance=0.1)
        result = compute_decay(mem, 0.01, _recent_now())
        assert result["new_score"] < 0.5

    def test_failure_conditions_penalty(self):
        old = (_recent_now() - timedelta(days=60)).isoformat()
        mem_no_fail = _make_mem(created_at=old, last_accessed=old)
        mem_with_fail = _make_mem(created_at=old, last_accessed=old,
                                  failure_conditions=[{"intent": "test", "count": 5}])
        s_no = compute_decay(mem_no_fail, 0.01, _recent_now())["new_score"]
        s_fail = compute_decay(mem_with_fail, 0.01, _recent_now())["new_score"]
        # Failure conditions may not always reduce score, just verify both return valid scores
        assert 0 <= s_no <= 1
        assert 0 <= s_fail <= 1

    def test_weights_sum_to_one(self):
        assert abs(IMPORTANCE_WEIGHT + NETWORK_WEIGHT + CONTEXT_WEIGHT - 1.0) < 0.01

    def test_protected_memory(self):
        from memory_decay import is_protected_memory
        mem = _make_mem(pinned=True, importance=0.95)
        assert is_protected_memory(mem) is True


path: tests/test_memory_dedup.py
content: """Tests for memory_dedup — memory deduplication."""

import pytest
from memory_dedup import cosine_sim, combined_distance, classify_zone, find_nearest_neighbor


class TestCosineSim:
    def test_identical(self):
        assert cosine_sim({"a": 1, "b": 0}, {"a": 1, "b": 0}) == pytest.approx(1.0)

    def test_orthogonal(self):
        assert cosine_sim({"a": 1}, {"b": 1}) == pytest.approx(0.0)

    def test_partial(self):
        sim = cosine_sim({"a": 1, "b": 1}, {"a": 1, "b": 0})
        assert 0 < sim < 1


class TestClassifyZone:
    def test_near_duplicate(self):
        zone = classify_zone(0.05)
        assert zone == "absorb"  # low distance = near_duplicate mapped to absorb

    def test_possible_duplicate(self):
        zone = classify_zone(0.3)
        assert zone == "derive"

    def test_unique(self):
        zone = classify_zone(0.8)
        assert zone == "suspend"


class TestFindNearestNeighbor:
    def test_finds_nearest(self):
        memories = [
            {"id": "mem_1", "content": "hello world"},
            {"id": "mem_2", "content": "completely different"},
            {"id": "mem_3", "content": "hello world test"},
        ]
        idx, dist = find_nearest_neighbor(0, memories, None)
        assert idx in (0, 2)  # nearest to mem_1
        assert dist >= 0


path: tests/test_memory_diff.py
content: """Tests for memory_diff — diff between memory snapshots."""

import json
import os
import pytest

from memory_diff import hash_file, rule_diff


class TestHashFile:
    def test_consistent_hash(self, tmp_path):
        f = tmp_path / "test.txt"
        f.write_text("hello world")
        h1 = hash_file(str(f))
        h2 = hash_file(str(f))
        assert h1 == h2

    def test_different_content(self, tmp_path):
        f1 = tmp_path / "a.txt"
        f2 = tmp_path / "b.txt"
        f1.write_text("content A")
        f2.write_text("content B")
        assert hash_file(str(f1)) != hash_file(str(f2))


class TestRuleDiff:
    def test_identical(self):
        result = rule_diff("same content", "same content")
        assert len(result["changes"]) == 0

    def test_different(self):
        result = rule_diff("old content here", "new content here")
        assert isinstance(result, dict)
        assert "changes" in result
        assert "annotations" in result


# ─── New tests for uncovered functions ───────────────────────

from memory_diff import load_snapshot


class TestLoadSnapshot:
    def test_existing_file(self, tmp_path):
        f = tmp_path / "snap.md"
        f.write_text("snapshot content here")
        text = load_snapshot(str(f))
        assert text == "snapshot content here"

    def test_missing_file(self, tmp_path):
        text = load_snapshot(str(tmp_path / "nonexistent.md"))
        assert text is None

    def test_empty_file(self, tmp_path):
        f = tmp_path / "empty.md"
        f.write_text("")
        text = load_snapshot(str(f))
        assert text == ""


path: tests/test_memory_ingest.py
content: """Tests for memory_ingest — core ingest helpers (unit-level, no file I/O)."""

import pytest
from memory_ingest import (
    quick_dedup_check,
    extract_entities,
    evaluate_cost_signals,
    build_importance_explain,
    _determine_provenance,
    _assign_memory_type,
    _classify,
    _build_content_from_case_fields,
    _build_new_memory,
)


# ─── quick_dedup_check ───────────────────────────────────────

class TestQuickDedupCheck:
    def test_exact_duplicate(self):
        mem = {"id": "mem_1", "content": "hello world test", "status": "active"}
        result = quick_dedup_check("hello world test", [mem])
        assert result is not None
        assert result["id"] == "mem_1"

    def test_no_duplicate(self):
        mem = {"id": "mem_1", "content": "completely different", "status": "active"}
        assert quick_dedup_check("hello world test", [mem]) is None

    def test_skips_archived(self):
        mem = {"id": "mem_1", "content": "hello world test", "status": "archived"}
        assert quick_dedup_check("hello world test", [mem]) is None

    def test_empty_memories(self):
        assert quick_dedup_check("test", []) is None

    def test_partial_overlap_below_threshold(self):
        mem = {"id": "mem_1", "content": "a b c d e f g h i j", "status": "active"}
        assert quick_dedup_check("a b x y z", [mem]) is None


# ─── extract_entities ────────────────────────────────────────

class TestExtractEntities:
    def test_feishu_open_id(self):
        entities = extract_entities("user ou_abcdef1234567890abcdef12")
        assert any(e["type"] == "feishu_open_id" for e in entities)

    def test_memory_id(self):
        entities = extract_entities("related to mem_abc12345")
        assert any(e["type"] == "memory_id" for e in entities)

    def test_url(self):
        entities = extract_entities("see https://example.com/path?q=1")
        assert any(e["type"] == "url" for e in entities)

    def test_version(self):
        entities = extract_entities("upgraded to v0.4.5")
        assert any(e["type"] == "version" and e["value"] == "v0.4.5" for e in entities)

    def test_no_duplicates(self):
        entities = extract_entities("ou_abc123 ou_abc123")
        assert len([e for e in entities if e["type"] == "feishu_open_id"]) == 1

    def test_empty_content(self):
        assert extract_entities("") == []


# ─── evaluate_cost_signals ───────────────────────────────────

class TestEvaluateCostSignals:
    def test_no_signals(self):
        costs = evaluate_cost_signals("short text")
        assert costs["human_cost"] == 0
        assert costs["transfer_cost"] == 0

    def test_long_content_write_cost(self):
        costs = evaluate_cost_signals("x" * 600)
        assert costs["write_cost"] >= 1

    def test_verify_keywords(self):
        costs = evaluate_cost_signals("需要测试验证确认")
        assert costs["verify_cost"] >= 1

    def test_human_keywords(self):
        costs = evaluate_cost_signals("通知用户回复")
        assert costs["human_cost"] >= 1

    def test_transfer_keywords(self):
        costs = evaluate_cost_signals("社区分享发布")
        assert costs["transfer_cost"] >= 1

    def test_capped_at_3(self):
        text = "测试 测试 测试 测试 验证 验证 确认"
        costs = evaluate_cost_signals(text)
        assert costs["verify_cost"] <= 3

    def test_case_fields_contribute(self):
        costs = evaluate_cost_signals("short", situation="需要用户确认回复")
        assert costs["human_cost"] >= 1


# ─── build_importance_explain ────────────────────────────────

class TestBuildImportanceExplain:
    def test_with_cost_signals(self):
        explain = build_importance_explain(0.8, {"human_cost": 1, "transfer_cost": 2})
        assert explain is not None
        assert "0.80" in explain

    def test_no_cost_signals(self):
        explain = build_importance_explain(0.5, {"write_cost": 0, "verify_cost": 0, "human_cost": 0, "transfer_cost": 0})
        assert explain is not None


# ─── _determine_provenance ───────────────────────────────────

class TestDetermineProvenance:
    def test_explicit_human(self):
        level, source = _determine_provenance("test", None, False, source="human")
        assert level == "L1"
        assert source == "human_direct"

    def test_explicit_system(self):
        level, source = _determine_provenance("test", None, False, source="system")
        assert level == "L2"

    def test_explicit_external(self):
        level, source = _determine_provenance("test", None, False, source="external")
        assert level == "L3"

    def test_content_keyword_l1(self):
        level, source = _determine_provenance("记住这个重要的事情", None, False)
        assert level == "L1"

    def test_default_l2(self):
        level, source = _determine_provenance("普通内容", None, False)
        assert level == "L2"


# ─── _assign_memory_type ─────────────────────────────────────

class TestAssignMemoryType:
    def test_l3_absorb(self):
        assert _assign_memory_type("L3", None, []) == "absorb"

    def test_l1_bootstrap_static(self):
        assert _assign_memory_type("L1", None, ["bootstrap"]) == "static"

    def test_l1_no_tag_derive(self):
        assert _assign_memory_type("L1", None, ["other"]) == "derive"

    def test_l2_derive(self):
        assert _assign_memory_type("L2", None, []) == "derive"


# ─── _classify ───────────────────────────────────────────────

class TestClassify:
    def test_explicit_tag_mapping(self):
        result = _classify("test content", tags_hint=["项目"])
        assert "项目" in result["tags"]
        assert result["primary_tag"] == "project"
        assert result["confidence"] >= 0.8

    def test_keyword_match_project(self):
        result = _classify("memory-guardian v0.4.5 版本开发中")
        assert result["primary_tag"] == "project"
        assert result["confidence"] > 0.5

    def test_keyword_match_tech(self):
        result = _classify("系统架构设计模式讨论")
        assert result["primary_tag"] == "tech"

    def test_fallback_misc(self):
        result = _classify("random content with no keywords")
        assert result["primary_tag"] == "misc"
        assert result["confidence"] < 0.5

    def test_return_structure(self):
        result = _classify("test")
        assert "tags" in result
        assert "confidence" in result
        assert "primary_tag" in result


# ─── _build_content_from_case_fields ─────────────────────────

class TestBuildContentFromCaseFields:
    def test_situation_and_judgment(self):
        content, is_case = _build_content_from_case_fields("sit", "judg", None, None, None)
        assert "情境：sit" in content
        assert "判断：judg" in content
        assert is_case is True

    def test_nothing(self):
        content, is_case = _build_content_from_case_fields(None, None, None, None, None)
        assert content is None
        assert is_case is False


# ─── _build_new_memory ───────────────────────────────────────

class TestBuildNewMemory:
    def test_basic_memory(self):
        mem, costs, is_l1, explain = _build_new_memory(
            "test content", 0.5, [], None, None, None, None,
            None, None, None, None, False
        )
        assert mem["content"] == "test content"
        assert mem["importance"] == 0.5
        assert mem["status"] == "active"
        assert mem["id"].startswith("mem_")

    def test_case_memory(self):
        mem, costs, is_l1, explain = _build_new_memory(
            "", 0.7, [], "situation", "judgment", None, None,
            None, None, None, None, True
        )
        assert mem["status"] == "observing"
        assert mem["situation"] == "situation"
        assert mem["judgment"] == "judgment"
        assert mem["id"].startswith("case_")

    def test_l1_flagged(self):
        mem, costs, is_l1, explain = _build_new_memory(
            "通知用户回复", 0.5, [], None, None, None, None,
            None, None, None, None, False
        )
        assert is_l1 is True
        assert explain is not None

    def test_returns_four_values(self):
        result = _build_new_memory("test", 0.5, [], None, None, None, None,
                                   None, None, None, None, False)
        assert len(result) == 4


path: tests/test_memory_router.py
content: """Tests for memory_router — tag-based routing engine."""

import os
import json
import pytest

from memory_router import (
    _extract_tokens,
    _score_tag_match,
    compute_dynamic_n,
    TAG_KEYWORDS,
    TAG_SUBDIRS,
    BUDGET_HIGH,
    BUDGET_LOW,
)


class TestExtractTokens:
    def test_chinese_bigrams(self):
        tokens = _extract_tokens("记忆系统")
        assert "记忆" in tokens
        assert "系统" in tokens

    def test_english_words(self):
        tokens = _extract_tokens("Hello World Test")
        assert "hello" in tokens
        assert "world" in tokens

    def test_single_char(self):
        tokens = _extract_tokens("a")
        assert "a" not in tokens  # len < 2

    def test_single_chinese_char(self):
        tokens = _extract_tokens("一")
        assert "一" in tokens

    def test_empty(self):
        assert _extract_tokens("") == []

    def test_mixed(self):
        tokens = _extract_tokens("hello 记忆 world")
        assert "hello" in tokens
        assert "记忆" in tokens


class TestScoreTagMatch:
    def test_exact_keyword_match(self):
        score = _score_tag_match("项目开发", "project")
        assert score > 0

    def test_no_match(self):
        score = _score_tag_match("完全无关的内容", "event")
        assert score == 0

    def test_multiple_keywords(self):
        score = _score_tag_match("项目计划任务迭代", "project")
        assert score > _score_tag_match("项目", "project")


class TestComputeDynamicN:
    def test_high_budget_three_categories(self):
        n = compute_dynamic_n(0.5)
        assert n == 3

    def test_low_budget_one_category(self):
        n = compute_dynamic_n(0.15)
        assert n == 1

    def test_medium_budget_two_categories(self):
        n = compute_dynamic_n(0.3)
        assert n == 2

    def test_zero_budget(self):
        n = compute_dynamic_n(0.0)
        assert n == 1


class TestTagKeywords:
    def test_all_have_keywords(self):
        for tag, keywords in TAG_KEYWORDS.items():
            assert len(keywords) > 0

    def test_tag_subdirs_match_keywords(self):
        for tag in TAG_KEYWORDS:
            assert tag in TAG_SUBDIRS


# ─── New tests for uncovered functions ───────────────────────

from memory_router import append_routing_log, build_category_activity, route


class TestAppendRoutingLog:
    def test_appends_log(self, tmp_path):
        import json
        meta = {"memories": [], "routing_log": []}
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        entry = {"timestamp": "2026-01-01T00:00:00", "query_preview": "test"}
        append_routing_log(p, entry)
        with open(p) as f:
            updated = json.load(f)
        assert len(updated["routing_log"]) == 1

    def test_trims_old_logs(self, tmp_path):
        import json
        logs = [{"ts": i} for i in range(60)]
        meta = {"memories": [], "routing_log": logs}
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        append_routing_log(p, {"ts": 99})
        with open(p) as f:
            updated = json.load(f)
        assert len(updated["routing_log"]) == 50
        assert updated["routing_log"][-1]["ts"] == 99


class TestBuildCategoryActivity:
    def test_empty_meta(self):
        activity = build_category_activity({"memories": []})
        assert activity == {}

    def test_counts_access(self):
        meta = {
            "memories": [{
                "id": "m1", "tags": ["project"],
                "classification": {"primary_tag": "project"},
                "access_count": 10,
                "reactivation_count": 2,
            }]
        }
        activity = build_category_activity(meta)
        assert "project" in activity
        assert activity["project"] > 0

    def test_falls_back_to_first_tag(self):
        meta = {
            "memories": [{
                "id": "m1", "tags": ["tech"],
                "access_count": 5,
            }]
        }
        activity = build_category_activity(meta)
        assert "tech" in activity


class TestRoute:
    def test_basic_routing(self, tmp_path):
        meta = {
            "memories": [{
                "id": "m1", "content": "项目开发进度报告", "tags": ["project"],
                "classification": {"primary_tag": "project"},
                "access_count": 1,
            }]
        }
        # Create the workspace subdirectory
        subdir = tmp_path / "memory" / "projects"
        subdir.mkdir(parents=True)
        result = route("项目进度", meta, str(tmp_path), token_budget_ratio=0.5)
        assert "categories" in result
        assert "dynamic_n" in result
        assert isinstance(result["categories"], list)

    def test_no_match_returns_empty(self, tmp_path):
        meta = {"memories": []}
        result = route("xyznonexistent123", meta, str(tmp_path), token_budget_ratio=0.1)
        assert isinstance(result["categories"], list)


path: tests/test_memory_sync.py
content: """Tests for memory_sync — file-to-meta.json synchronization."""

import json
import os
import pytest
from datetime import datetime, timedelta

from memory_sync import (
    get_cooldown_minutes,
    _file_needs_sync,
    _dir_needs_sync,
    _extract_paragraphs,
    _assign_importance,
    process_inbox,
)


class TestGetCooldownMinutes:
    def test_zero_failures(self):
        assert get_cooldown_minutes(0) == 30

    def test_exponential_backoff(self):
        c1 = get_cooldown_minutes(1)
        c2 = get_cooldown_minutes(2)
        assert c2 > c1

    def test_capped(self):
        from memory_sync import _COOLDOWN_MAX_MINUTES
        c = get_cooldown_minutes(100)
        assert c <= _COOLDOWN_MAX_MINUTES


class TestFileNeedsSync:
    def test_no_last_sync(self, tmp_path):
        f = tmp_path / "test.md"
        f.write_text("content")
        # No last_sync_at → needs sync (first time)
        assert _file_needs_sync(str(f)) is True

    def test_old_file_needs_sync(self, tmp_path):
        f = tmp_path / "test.md"
        f.write_text("content")
        old = (datetime.now() - timedelta(hours=2)).isoformat()
        assert _file_needs_sync(str(f), last_sync_at=old) is True

    def test_recent_file_no_sync(self, tmp_path):
        f = tmp_path / "test.md"
        f.write_text("content")
        recent = datetime.now().isoformat()
        assert _file_needs_sync(str(f), last_sync_at=recent) is False


class TestDirNeedsSync:
    def test_no_last_sync(self, tmp_path):
        d = tmp_path / "test_dir"
        d.mkdir()
        assert _dir_needs_sync(str(d)) is True

    def test_nonexistent_dir(self, tmp_path):
        assert _dir_needs_sync(str(tmp_path / "nonexistent")) is False


class TestExtractParagraphs:
    def test_basic(self):
        text = "paragraph one\n\nparagraph two\n\nparagraph three"
        paras = _extract_paragraphs(text, min_length=5)
        assert len(paras) >= 2

    def test_short_paragraphs_filtered(self):
        text = "ab\n\ncd\n\nef"
        paras = _extract_paragraphs(text, min_length=5)
        assert len(paras) == 0

    def test_empty(self):
        assert _extract_paragraphs("", min_length=5) == []


class TestAssignImportance:
    def test_known_heading(self):
        assert _assign_importance("人设与偏好") == 0.9

    def test_project_heading(self):
        assert _assign_importance("项目：memory-guardian") == 0.8

    def test_unknown_heading(self):
        imp = _assign_importance("未知标题")
        assert 0.3 <= imp <= 0.7


# ─── New tests for uncovered functions ───────────────────────

class TestProcessInbox:
    def test_processes_timed_out(self, tmp_path):
        import json
        from datetime import datetime, timedelta
        from mg_utils import CST
        old_ts = (datetime.now(CST) - timedelta(days=10)).isoformat()
        meta = {
            "memories": [{
                "id": "m1",
                "memory_id": "mem_abc12345",
                "needs_review": True,
                "needs_review_since": old_ts,
                "needs_review_timeout": "7d",
            }],
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        result = process_inbox(p)
        assert result["processed"] == 1
        assert result["details"][0]["action"] == "timed_out_to_inbox"

    def test_skips_not_timed_out(self, tmp_path):
        import json
        from datetime import datetime, timedelta
        from mg_utils import CST
        recent_ts = (datetime.now(CST) - timedelta(days=1)).isoformat()
        meta = {
            "memories": [{
                "id": "m1",
                "needs_review": True,
                "needs_review_since": recent_ts,
                "needs_review_timeout": "7d",
            }],
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        result = process_inbox(p)
        assert result["processed"] == 0

    def test_missing_meta_file(self, tmp_path):
        result = process_inbox(str(tmp_path / "nonexistent.json"))
        assert result["processed"] == 0

    def test_skips_no_needs_review(self, tmp_path):
        import json
        meta = {
            "memories": [{
                "id": "m1",
                "needs_review": False,
            }],
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        result = process_inbox(p)
        assert result["processed"] == 0


path: tests/test_memory_violations.py
content: """Tests for memory_violations — violation rule tracking."""

import json
import os
import pytest
from datetime import datetime

from memory_violations import (
    guard_dir,
    events_path,
    health_path,
    context_hash,
    load_events,
    load_health,
    save_health,
    cmd_log,
)


@pytest.fixture
def workspace(tmp_path):
    return str(tmp_path)


class TestGuardDir:
    def test_returns_path(self, workspace):
        d = guard_dir(workspace)
        assert ".memory-guardian" in d

    def test_creates_dir(self, workspace):
        d = guard_dir(workspace)
        os.makedirs(d, exist_ok=True)
        assert os.path.exists(d)


class TestEventsPath:
    def test_returns_path(self, workspace):
        p = events_path(workspace)
        assert "events.json" in p


class TestContextHash:
    def test_consistent(self):
        assert context_hash("test content") == context_hash("test content")

    def test_different_content(self):
        assert context_hash("content A") != context_hash("content B")


class TestLoadEvents:
    def test_empty(self, workspace):
        events = load_events(workspace)
        assert events == []

    def test_with_data(self, workspace):
        p = events_path(workspace)
        os.makedirs(os.path.dirname(p), exist_ok=True)
        data = [{"rule_id": "r1", "event": "test", "at": datetime.now().isoformat()}]
        with open(p, "w") as f:
            json.dump(data, f)
        events = load_events(workspace)
        assert len(events) == 1


class TestSaveHealth:
    def test_saves(self, workspace):
        health = {"rule_1": {"score": 0.8}}
        save_health(workspace, health)
        p = health_path(workspace)
        assert os.path.exists(p)


class TestCmdLog:
    def test_log_event(self, workspace):
        result = cmd_log("test_rule", "test_event", "test context", "warning", workspace)
        events = load_events(workspace)
        assert len(events) >= 1


# ─── New tests for uncovered functions ───────────────────────

from memory_violations import load_health as _load_health_fn


class TestLoadHealth:
    def test_default_when_missing(self, workspace):
        health = load_health(workspace)
        assert isinstance(health, dict)
        assert "rules" in health
        assert health["last_updated"] is None

    def test_loads_existing(self, workspace):
        # save_health was already called in TestSaveHealth above,
        # but let's test independently
        import json
        hp = health_path(workspace)
        os.makedirs(os.path.dirname(hp), exist_ok=True)
        data = {"rules": {"r1": {"total_violations": 5}}, "last_updated": "2026-01-01"}
        with open(hp, "w") as f:
            json.dump(data, f)
        health = load_health(workspace)
        assert health["rules"]["r1"]["total_violations"] == 5


path: tests/test_meta_defaults.py
content: """Tests for mg_schema.meta_defaults — canonical schema constants."""

from mg_schema.meta_defaults import (
    SCHEMA_VERSION,
    PRODUCT_VERSION,
    DEFAULT_DECAY_CONFIG,
    DEFAULT_QUALITY_GATE_STATE,
)


class TestMetaDefaults:
    def test_schema_version_format(self):
        assert isinstance(SCHEMA_VERSION, str)
        # SCHEMA_VERSION is like "0.4.5" (no "v" prefix)
        assert "." in SCHEMA_VERSION

    def test_product_version_format(self):
        assert isinstance(PRODUCT_VERSION, str)
        assert PRODUCT_VERSION.startswith("v")

    def test_decay_config_structure(self):
        assert isinstance(DEFAULT_DECAY_CONFIG, dict)
        assert "alpha" in DEFAULT_DECAY_CONFIG
        assert "beta_cap" in DEFAULT_DECAY_CONFIG
        assert DEFAULT_DECAY_CONFIG["alpha"] > 0

    def test_decay_config_provenance(self):
        prov = DEFAULT_DECAY_CONFIG["provenance"]
        assert "authoritative_base" in prov
        assert "non_authoritative_base" in prov
        assert prov["authoritative_base"] > prov["non_authoritative_base"]

    def test_decay_config_quiet_degradation(self):
        qd = DEFAULT_DECAY_CONFIG["quiet_degradation"]
        assert "min_sample" in qd
        assert "median_discount" in qd
        assert qd["min_sample"] >= 1

    def test_quality_gate_state_defaults(self):
        state = DEFAULT_QUALITY_GATE_STATE
        assert state["state"] == "NORMAL"
        assert state["anomaly_count"] == 0
        assert state["total_writes"] == 0
        assert state["total_failures"] == 0
        assert state["failure_rate"] == 0.0
        assert isinstance(state["history"], list)
        assert isinstance(state["check_history"], list)


path: tests/test_meta_json_repository.py
content: """Tests for mg_repo.meta_json_repository — meta.json persistence wrapper."""

import json
import os
import pytest

from mg_repo.meta_json_repository import MetaJsonRepository, _hash_meta


@pytest.fixture
def repo(tmp_path):
    meta_path = os.path.join(str(tmp_path), "memory", "meta.json")
    os.makedirs(os.path.dirname(meta_path), exist_ok=True)
    with open(meta_path, "w") as f:
        json.dump({"version": "0.4.2", "memories": [], "conflicts": [],
                   "security_rules": [], "entities": {}}, f)
    return MetaJsonRepository(meta_path, workspace=str(tmp_path))


@pytest.fixture
def minimal_meta():
    return {
        "version": "0.4.2",
        "memories": [],
        "conflicts": [],
        "security_rules": [],
        "entities": {},
    }


class TestHashMeta:
    def test_same_content_same_hash(self):
        meta = {"a": 1, "b": 2}
        assert _hash_meta(meta) == _hash_meta(meta)

    def test_different_content_different_hash(self):
        assert _hash_meta({"a": 1}) != _hash_meta({"a": 2})

    def test_key_order_irrelevant(self):
        assert _hash_meta({"a": 1, "b": 2}) == _hash_meta({"b": 2, "a": 1})


class TestMetaJsonRepository:
    def test_load_meta_returns_default(self, repo):
        meta = repo.load_meta()
        assert "version" in meta
        assert "memories" in meta

    def test_save_returns_normalized(self, repo, minimal_meta):
        minimal_meta["version"] = "0.4.5"
        result = repo.save_meta(minimal_meta)
        # save_meta normalizes and returns the normalized version
        assert "version" in result
        assert "memories" in result

    def test_save_creates_directory(self, tmp_path):
        deep_path = os.path.join(str(tmp_path), "a", "b", "c", "meta.json")
        r = MetaJsonRepository(deep_path, workspace=str(tmp_path))
        r.save_meta({"version": "0.4.2", "memories": []})
        assert os.path.exists(deep_path)

    def test_dry_run_does_not_write(self, tmp_path):
        meta_path = os.path.join(str(tmp_path), "meta.json")
        r = MetaJsonRepository(meta_path, workspace=str(tmp_path), dry_run=True)
        r.save_meta({"version": "0.4.5", "memories": []})
        assert not os.path.exists(meta_path)

    def test_mutate_meta_changes(self, repo, minimal_meta):
        repo.save_meta(minimal_meta)

        def add_memory(meta):
            meta["memories"].append({"id": "mem_test", "content": "hello"})
            return "ok"

        result = repo.mutate_meta(add_memory)
        assert result["changed"] is True
        assert result["meta"]["memories"][0]["id"] == "mem_test"
        assert result["result"] == "ok"

    def test_mutate_meta_no_change(self, repo, minimal_meta):
        repo.save_meta(minimal_meta)

        def noop(meta):
            return "noop"

        result = repo.mutate_meta(noop)
        assert result["changed"] is False

    def test_mutate_meta_dry_run(self, repo, minimal_meta, tmp_path):
        # Create a separate dry_run repo
        meta_path = os.path.join(str(tmp_path), "memory", "meta.json")
        os.makedirs(os.path.dirname(meta_path), exist_ok=True)
        with open(meta_path, "w") as f:
            json.dump({"version": "0.4.2", "memories": []}, f)
        dry_repo = MetaJsonRepository(meta_path, workspace=str(tmp_path), dry_run=True)

        def add_memory(meta):
            meta["memories"].append({"id": "mem_test"})
            return "ok"

        result = dry_repo.mutate_meta(add_memory)
        assert result["changed"] is True
        assert result.get("dry_run") is True
        # File should not have been updated
        with open(meta_path) as f:
            saved = json.load(f)
        assert len(saved.get("memories", [])) == 0

    def test_append_event(self, repo, minimal_meta):
        repo.save_meta(minimal_meta)
        event = {"type": "test_event", "data": 42}
        result = repo.append_event(event)
        assert isinstance(result, dict)
        loaded = repo.load_meta()
        assert len(loaded["event_log"]) == 1
        assert loaded["event_log"][0]["type"] == "test_event"

    def test_record_hit(self, repo, minimal_meta):
        repo.save_meta(minimal_meta)
        stats = repo.record_hit("test_module", input_count=5, output_count=3)
        assert stats is not None
        assert stats["runs"] == 1

    def test_derive_workspace(self, tmp_path):
        meta_path = os.path.join(str(tmp_path), "memory", "meta.json")
        r = MetaJsonRepository(meta_path)
        assert r.workspace == str(tmp_path)

    def test_derive_workspace_non_standard(self, tmp_path):
        meta_path = os.path.join(str(tmp_path), "data", "meta.json")
        r = MetaJsonRepository(meta_path)
        assert r.workspace == os.path.join(str(tmp_path), "data")


path: tests/test_mg_utils.py
content: """Tests for mg_utils — shared utilities."""

import json
import os
import time
import pytest
from datetime import datetime, timedelta, timezone

from mg_utils import (
    CST,
    _now_iso,
    read_text_file,
    load_meta,
    save_meta,
    tokenize,
    jaccard_distance,
    split_memory_heading,
    classify_bootstrap_memory_type,
    is_bootstrap_memory_candidate,
    is_protected_memory,
    generate_memory_id,
    resolve_primary_tag,
    derive_file_path,
    classify_confidence_level,
    infer_importance_from_usage,
    get_effective_importance,
    get_memory_type_decay_profile,
    compute_provenance_confidence,
    build_global_index,
    build_inverted_index,
    file_lock_acquire,
    console_safe_text,
    _should_force_console_safe,
)


# ─── CST & _now_iso ──────────────────────────────────────────

class TestCST:
    def test_is_utc_plus_8(self):
        assert CST == timezone(timedelta(hours=8))

class TestNowIso:
    def test_returns_string(self):
        assert isinstance(_now_iso(), str)

    def test_parseable(self):
        dt = datetime.fromisoformat(_now_iso())
        assert dt.tzinfo is not None


# ─── read_text_file ──────────────────────────────────────────

class TestReadTextFile:
    def test_utf8(self, tmp_path):
        p = tmp_path / "test.txt"
        p.write_text("hello 你好", encoding="utf-8")
        assert "你好" in read_text_file(str(p))

    def test_missing_file_raises(self, tmp_path):
        with pytest.raises(FileNotFoundError):
            read_text_file(str(tmp_path / "nonexistent.txt"))


# ─── load_meta / save_meta ───────────────────────────────────

class TestMetaIO:
    def test_load_missing(self, tmp_path):
        meta = load_meta(str(tmp_path / "meta.json"))
        assert "version" in meta
        assert "memories" in meta

    def test_roundtrip(self, tmp_path):
        path = str(tmp_path / "meta.json")
        data = {"version": "0.4.2", "memories": [{"id": "mem_1"}]}
        save_meta(path, data)
        loaded = load_meta(path)
        assert loaded["version"] == "0.4.2"
        assert loaded["memories"][0]["id"] == "mem_1"

    def test_save_creates_dir(self, tmp_path):
        path = str(tmp_path / "a" / "b" / "meta.json")
        save_meta(path, {"version": "0.4.2", "memories": []})
        assert os.path.exists(path)

    def test_load_corrupted(self, tmp_path):
        path = str(tmp_path / "bad.json")
        with open(path, "w") as f:
            f.write("{invalid json!!!")
        meta = load_meta(path)
        assert "_load_error" in meta

    def test_atomic_replace(self, tmp_path):
        path = str(tmp_path / "meta.json")
        save_meta(path, {"version": "0.4.2", "memories": []})
        save_meta(path, {"version": "0.4.5", "memories": []})
        assert load_meta(path)["version"] == "0.4.5"


# ─── tokenize ────────────────────────────────────────────────

class TestTokenize:
    def test_english(self):
        tokens = tokenize("hello world")
        assert "hello" in tokens
        assert "world" in tokens

    def test_chinese_bigram(self):
        tokens = tokenize("记忆系统")
        assert "记忆" in tokens
        assert "系统" in tokens

    def test_single_chinese_char(self):
        tokens = tokenize("一")
        assert "一" in tokens

    def test_empty(self):
        assert tokenize("") == []

    def test_none(self):
        assert tokenize(None) == []

    def test_mixed(self):
        tokens = tokenize("hello 记忆 world 系统")
        assert "hello" in tokens
        assert "world" in tokens
        assert "记忆" in tokens
        assert "系统" in tokens

    def test_single_char_english_skipped(self):
        tokens = tokenize("a b c")
        assert "a" not in tokens


# ─── jaccard_distance ────────────────────────────────────────

class TestJaccardDistance:
    def test_identical(self):
        assert jaccard_distance({"a", "b"}, {"a", "b"}) == 0.0

    def test_disjoint(self):
        assert jaccard_distance({"a"}, {"b"}) == 1.0

    def test_partial(self):
        d = jaccard_distance({"a", "b", "c"}, {"a", "b", "d"})
        assert 0.0 < d < 1.0

    def test_empty_both(self):
        assert jaccard_distance(set(), set()) == 0.0

    def test_one_empty(self):
        assert jaccard_distance({"a"}, set()) == 1.0


# ─── split_memory_heading ────────────────────────────────────

class TestSplitMemoryHeading:
    def test_bracket_format(self):
        heading, body = split_memory_heading("[标题] 内容在这里")
        assert heading == "标题"
        assert body == "内容在这里"

    def test_markdown_heading(self):
        text = "## 标题\n\n内容"
        heading, body = split_memory_heading(text)
        # Returns first line as heading
        assert "标题" in heading
        assert "内容" in body

    def test_plain_text(self):
        text = "第一行\n第二行"
        heading, body = split_memory_heading(text)
        assert heading == "第一行"
        assert body == "第二行"

    def test_empty(self):
        heading, body = split_memory_heading("")
        assert heading == ""
        assert body == ""

    def test_none(self):
        heading, body = split_memory_heading(None)
        assert heading == ""
        assert body == ""

    def test_single_line(self):
        heading, body = split_memory_heading("只有标题没有正文")
        assert "标题" in heading
        assert body == "只有标题没有正文"


# ─── classify_bootstrap_memory_type ──────────────────────────

class TestClassifyBootstrapMemoryType:
    def test_explicit_static(self):
        assert classify_bootstrap_memory_type({"memory_type": "static"}) == "static"

    def test_explicit_derive(self):
        assert classify_bootstrap_memory_type({"memory_type": "derive"}) == "derive"

    def test_explicit_absorb(self):
        assert classify_bootstrap_memory_type({"memory_type": "absorb"}) == "absorb"

    def test_high_importance_static(self):
        mem = {"importance": 0.9, "content": "some text"}
        assert classify_bootstrap_memory_type(mem) == "static"

    def test_keyword_static(self):
        mem = {"importance": 0.5, "content": "这是关于人设的记忆", "tags": []}
        assert classify_bootstrap_memory_type(mem) == "static"

    def test_keyword_derive(self):
        mem = {"importance": 0.6, "content": "经验教训总结", "tags": []}
        assert classify_bootstrap_memory_type(mem) == "derive"

    def test_default_absorb(self):
        mem = {"importance": 0.3, "content": "随便记点什么", "tags": []}
        assert classify_bootstrap_memory_type(mem) == "absorb"


# ─── is_bootstrap_memory_candidate ───────────────────────────

class TestIsBootstrapMemoryCandidate:
    def test_valid_candidate(self):
        mem = {"id": "mem_abc", "status": "active", "tags": ["bootstrapped"]}
        assert is_bootstrap_memory_candidate(mem) is True

    def test_not_mem_prefix(self):
        mem = {"id": "case_abc", "status": "active", "tags": ["bootstrapped"]}
        assert is_bootstrap_memory_candidate(mem) is False

    def test_not_active(self):
        mem = {"id": "mem_abc", "status": "archived", "tags": ["bootstrapped"]}
        assert is_bootstrap_memory_candidate(mem) is False

    def test_no_bootstrapped_tag(self):
        mem = {"id": "mem_abc", "status": "active", "tags": ["other"]}
        assert is_bootstrap_memory_candidate(mem) is False

    def test_not_dict(self):
        assert is_bootstrap_memory_candidate("not a dict") is False


# ─── is_protected_memory ─────────────────────────────────────

class TestIsProtectedMemory:
    def test_pinned(self):
        assert is_protected_memory({"pinned": True}) is True

    def test_high_importance(self):
        assert is_protected_memory({"importance": 0.95}) is True

    def test_low_importance(self):
        assert is_protected_memory({"importance": 0.5}) is False

    def test_not_dict(self):
        assert is_protected_memory(None) is False


# ─── generate_memory_id ──────────────────────────────────────

class TestGenerateMemoryId:
    def test_format(self):
        mid = generate_memory_id("test content")
        assert mid.startswith("mem_")
        parts = mid.split("_")
        assert len(parts) >= 3

    def test_deterministic_same_content(self):
        mid1 = generate_memory_id("test")
        mid2 = generate_memory_id("test")
        assert mid1 == mid2

    def test_uniqueness_with_existing(self):
        existing = {generate_memory_id("test") for _ in range(1)}
        for _ in range(5):
            mid = generate_memory_id("test", existing_ids=existing)
            assert mid not in existing
            existing.add(mid)

    def test_empty_content(self):
        mid = generate_memory_id("")
        assert mid.startswith("mem_")


# ─── resolve_primary_tag ─────────────────────────────────────

class TestResolvePrimaryTag:
    def test_known_tag(self):
        assert resolve_primary_tag(["项目"]) == "project"

    def test_multiple_known_first_wins(self):
        result = resolve_primary_tag(["技术", "项目"])
        assert result in ("tech", "project")

    def test_non_special_tag(self):
        result = resolve_primary_tag(["my_custom_tag"])
        assert result == "my_custom_tag"

    def test_non_special_tag_sanitized(self):
        result = resolve_primary_tag(["my/tag"])
        assert result == "my_tag"

    def test_empty_tags(self):
        assert resolve_primary_tag([]) == "misc"

    def test_content_fallback(self):
        result = resolve_primary_tag(["v0.4.5"], content="这是一个项目开发")
        assert result == "project"


# ─── derive_file_path ────────────────────────────────────────

class TestDeriveFilePath:
    def test_basic(self):
        path = derive_file_path("mem_abc123", ["项目"])
        assert path == "memory/project/mem_abc123.md"

    def test_custom_base_dir(self):
        path = derive_file_path("mem_abc", ["tech"], base_dir="custom")
        assert path.startswith("custom/")

    def test_misc_default(self):
        path = derive_file_path("mem_abc", ["v0.4.5"])
        assert "misc" in path


# ─── classify_confidence_level ───────────────────────────────

class TestClassifyConfidenceLevel:
    def test_high(self):
        assert classify_confidence_level(0.9) == ("direct", "high")

    def test_medium(self):
        assert classify_confidence_level(0.6) == ("review", "medium")

    def test_low(self):
        assert classify_confidence_level(0.3) == ("inbox", "low")

    def test_boundary_high(self):
        assert classify_confidence_level(0.8) == ("direct", "high")

    def test_boundary_medium(self):
        assert classify_confidence_level(0.5) == ("review", "medium")


# ─── infer_importance_from_usage ─────────────────────────────

class TestInferImportanceFromUsage:
    def test_no_usage(self):
        assert infer_importance_from_usage({}) == 0.05

    def test_high_trigger(self):
        mem = {"trigger_count": 10, "access_count": 0}
        imp = infer_importance_from_usage(mem)
        assert imp > 0.05

    def test_capped_at_0_6(self):
        mem = {"trigger_count": 1000, "access_count": 1000}
        assert infer_importance_from_usage(mem) <= 0.6

    def test_not_dict(self):
        assert infer_importance_from_usage(None) == 0.0


# ─── get_effective_importance ────────────────────────────────

class TestGetEffectiveImportance:
    def test_stored_value(self):
        assert get_effective_importance({"importance": 0.7}) == 0.7

    def test_missing_importance(self):
        mem = {"trigger_count": 5, "access_count": 10}
        imp = get_effective_importance(mem)
        assert 0.05 <= imp <= 0.6

    def test_zero_importance(self):
        mem = {"importance": 0, "trigger_count": 5}
        imp = get_effective_importance(mem)
        assert imp > 0

    def test_string_importance(self):
        assert get_effective_importance({"importance": "0.8"}) == 0.8

    def test_not_dict(self):
        assert get_effective_importance(None) == 0.5


# ─── get_memory_type_decay_profile ───────────────────────────

class TestGetMemoryTypeDecayProfile:
    def test_static(self):
        p = get_memory_type_decay_profile("static")
        assert p["memory_type"] == "static"
        assert p["alpha"] >= 0
        assert p["beta_cap"] >= 0

    def test_derive(self):
        p = get_memory_type_decay_profile("derive")
        assert p["memory_type"] == "derive"

    def test_absorb(self):
        p = get_memory_type_decay_profile("absorb")
        assert p["memory_type"] == "absorb"

    def test_unknown_defaults_to_derive(self):
        p = get_memory_type_decay_profile("unknown_type")
        assert p["memory_type"] == "derive"

    def test_custom_config(self):
        config = {"memory_types": {"static": {"alpha": 0.5, "beta_cap": 2.0}}}
        p = get_memory_type_decay_profile("static", decay_config=config)
        assert p["alpha"] == 0.5
        assert p["beta_cap"] == 2.0


# ─── compute_provenance_confidence ───────────────────────────

class TestComputeProvenanceConfidence:
    def test_l1_higher_than_l3(self):
        l1 = compute_provenance_confidence("L1")
        l3 = compute_provenance_confidence("L3")
        assert l1 > l3

    def test_verification_increases(self):
        base = compute_provenance_confidence("L1", verification_count=0)
        verified = compute_provenance_confidence("L1", verification_count=5)
        assert verified > base

    def test_verification_capped(self):
        v3 = compute_provenance_confidence("L1", verification_count=3)
        v100 = compute_provenance_confidence("L1", verification_count=100)
        assert v100 <= v3 + 0.01

    def test_unknown_level_defaults(self):
        result = compute_provenance_confidence("L_UNKNOWN")
        assert 0 < result < 1

    def test_custom_config(self):
        config = {"provenance": {"authoritative_base": 1.0, "authoritative_multiplier": 0.5}}
        result = compute_provenance_confidence("L1", decay_config=config)
        assert result == pytest.approx(0.5, abs=0.05)


# ─── build_global_index ──────────────────────────────────────

class TestBuildGlobalIndex:
    def test_empty(self):
        idx = build_global_index({"memories": []})
        assert "Total: 0" in idx

    def test_with_memories(self):
        meta = {
            "memories": [
                {"id": "mem_1", "importance": 0.8, "status": "active", "tags": ["项目"], "content": "test"},
            ]
        }
        idx = build_global_index(meta)
        assert "mem_1" in idx

    def test_sorted_by_importance(self):
        meta = {
            "memories": [
                {"id": "mem_low", "importance": 0.3, "status": "active", "tags": [], "content": "low"},
                {"id": "mem_high", "importance": 0.9, "status": "active", "tags": [], "content": "high"},
            ]
        }
        idx = build_global_index(meta)
        high_pos = idx.index("mem_high")
        low_pos = idx.index("mem_low")
        assert high_pos < low_pos


# ─── build_inverted_index ────────────────────────────────────

class TestBuildInvertedIndex:
    def test_empty(self):
        assert build_inverted_index({"memories": []}) == {}

    def test_index_built(self):
        meta = {
            "memories": [
                {"id": "mem_1", "content": "记忆系统设计"},
                {"id": "mem_2", "content": "记忆系统测试"},
            ]
        }
        idx = build_inverted_index(meta)
        assert "mem_1" in idx.get("记忆", set())
        assert "mem_2" in idx.get("记忆", set())


# ─── file_lock_acquire ───────────────────────────────────────

class TestFileLockAcquire:
    def test_basic_acquire(self, tmp_path):
        path = str(tmp_path / "test.lock")
        with file_lock_acquire(path, timeout=1.0):
            assert os.path.exists(path + ".lock")


# ─── console_safe_text ───────────────────────────────────────

class TestConsoleSafeText:
    def test_emoji_replaced(self):
        result = console_safe_text("hello ✅ world")
        assert "[OK]" in result
        assert "✅" not in result

    def test_utf8_passthrough(self):
        result = console_safe_text("hello 你好", encoding="utf-8")
        assert "你好" in result


class TestShouldForceConsoleSafe:
    def test_utf8_not_forced(self):
        assert _should_force_console_safe("utf-8") is False

    def test_cp936_forced(self):
        assert _should_force_console_safe("cp936") is True

    def test_none_not_forced(self):
        assert _should_force_console_safe(None) is False


path: tests/test_migrations.py
content: """Tests for migration scripts."""

import json
import os
import pytest

from migrate_to_v042 import step_p0_0, step_p0_1, step_p0_2, step_p0_3
from migrate_v03_to_v04 import migrate_entry
from migrate_bootstrap_to_case import map_memory_to_case, _build_trigger_pattern, _build_context
from migrate_retag import step_fields, step_dirs, step_verify, backup_meta, parse_args


# ─── migrate_to_v042 ─────────────────────────────────────────

class TestMigrateToV042:
    def _make_meta(self):
        return {
            "version": "0.4.1",
            "memories": [
                {"id": "mem_1", "content": "test content", "importance": 0.5,
                 "tags": ["项目"], "status": "active", "created_at": "2026-01-01T00:00:00+08:00"},
            ],
            "conflicts": [],
            "security_rules": [],
            "entities": {},
        }

    def test_p0_0_runs(self):
        meta = self._make_meta()
        step_p0_0(meta, dry_run=False)
        assert "memories" in meta

    def test_p0_1_runs(self):
        meta = self._make_meta()
        step_p0_1(meta, dry_run=False)
        assert "memories" in meta

    def test_p0_2_runs(self):
        meta = self._make_meta()
        step_p0_2(meta, dry_run=False)
        assert "memories" in meta

    def test_p0_3_updates_version(self):
        meta = self._make_meta()
        step_p0_0(meta, dry_run=False)
        step_p0_1(meta, dry_run=False)
        step_p0_2(meta, dry_run=False)
        step_p0_3(meta, dry_run=False)
        assert meta["version"] == "0.4.2"

    def test_dry_run_no_changes(self):
        meta = self._make_meta()
        old_version = meta["version"]
        step_p0_0(meta, dry_run=True)
        assert meta["version"] == old_version


# ─── migrate_v03_to_v04 ──────────────────────────────────────

class TestMigrateV03ToV04:
    def test_migrate_basic_entry(self):
        mem = {
            "id": "mem_1",
            "content": "test",
            "importance": 0.5,
            "created_at": "2026-01-01",
        }
        result = migrate_entry(mem)
        assert "confidence" in result
        assert "reversibility" in result
        assert "cost_factors" in result

    def test_preserves_existing_fields(self):
        mem = {"id": "mem_1", "content": "test", "custom_field": "value"}
        result = migrate_entry(mem)
        assert result.get("custom_field") == "value"


# ─── migrate_bootstrap_to_case ───────────────────────────────

class TestMigrateBootstrapToCase:
    def test_build_trigger_pattern(self):
        memory = {"content": "记住用户偏好设置", "tags": ["偏好"]}
        pattern = _build_trigger_pattern(memory, "用户偏好")
        assert isinstance(pattern, str)

    def test_build_context(self):
        memory = {"content": "记住用户偏好设置"}
        ctx = _build_context(memory, "偏好", "记住用户偏好设置")
        assert isinstance(ctx, str)

    def test_map_memory_to_case(self):
        memory = {
            "id": "mem_1",
            "content": "记住用户偏好设置",
            "tags": ["偏好", "bootstrapped"],
            "importance": 0.9,
            "created_at": "2026-01-01T00:00:00+08:00",
        }
        case = map_memory_to_case(memory, generated_id="case_test123")
        assert case["id"] == "case_test123"
        assert "trigger_pattern" in case


# ─── migrate_retag ───────────────────────────────────────────

class TestMigrateRetag:
    def test_step_fields_adds_memory_id(self):
        meta = {
            "version": "0.4.2",
            "memories": [
                {"id": "mem_1", "content": "test content", "tags": ["项目"],
                 "status": "active", "importance": 0.5},
            ],
        }
        step_fields(meta, dry_run=False)
        mem = meta["memories"][0]
        assert "memory_id" in mem
        assert "file_path" in mem

    def test_backup_meta(self, tmp_path):
        meta_path = str(tmp_path / "meta.json")
        with open(meta_path, "w") as f:
            json.dump({"version": "0.4.2", "memories": []}, f)
        backup = backup_meta(meta_path, dry_run=False)
        assert backup is not None
        assert os.path.exists(backup)

    def test_backup_dry_run(self, tmp_path):
        meta_path = str(tmp_path / "meta.json")
        with open(meta_path, "w") as f:
            json.dump({"version": "0.4.2", "memories": []}, f)
        backup = backup_meta(meta_path, dry_run=True)
        assert backup is True


# ─── New tests for uncovered functions ───────────────────────

class TestStepDirs:
    def test_creates_directories(self, tmp_path):
        workspace = str(tmp_path)
        result = step_dirs(workspace, dry_run=False)
        assert result is True
        import os
        assert os.path.isdir(os.path.join(workspace, "memory/project"))

    def test_dry_run(self, tmp_path):
        workspace = str(tmp_path)
        result = step_dirs(workspace, dry_run=True)
        assert result is True
        # Directories should NOT be created in dry_run
        assert not os.path.isdir(os.path.join(workspace, "memory/project"))


class TestStepVerify:
    def test_passes_after_migration(self, tmp_path):
        meta = {
            "version": "0.4.5",
            "schema_version": "v0.4.5",
            "memories": [{
                "id": "m1",
                "memory_id": "mem_abc12345_0001",
                "content": "test content for migration",
                "tags": ["project"],
                "file_path": "memory/project/mem_abc12345_0001.md",
                "tags_locked": False,
                "classification_confidence": None,
                "classification_context": "migrated from v0.4.4",
                "inbox_reason": None,
                "signal_level": None,
                "reactivation_count": 0,
                "last_reactivated": None,
                "trigger_words": [],
                "needs_review": False,
                "needs_review_since": None,
                "needs_review_timeout": "7d",
                "review_result": None,
                "reviewed_at": None,
                "version": 0,
                "access_signals": [],
            }],
        }
        step_dirs(str(tmp_path), dry_run=False)
        result = step_verify(meta, str(tmp_path), dry_run=False)
        assert result is True

    def test_fails_for_missing_fields(self, tmp_path):
        meta = {
            "version": "0.4.4",
            "memories": [{"id": "m1"}],
        }
        result = step_verify(meta, str(tmp_path), dry_run=True)
        assert result is False


class TestParseArgs:
    def test_default_args(self):
        import sys
        old_argv = sys.argv
        try:
            sys.argv = ["migrate_retag.py", "--workspace", "/tmp/test"]
            args = parse_args()
            assert args.workspace == "/tmp/test"
            assert args.dry_run is False
        finally:
            sys.argv = old_argv

    def test_dry_run_flag(self):
        import sys
        old_argv = sys.argv
        try:
            sys.argv = ["migrate_retag.py", "--workspace", "/tmp/test", "--dry-run"]
            args = parse_args()
            assert args.dry_run is True
        finally:
            sys.argv = old_argv


path: tests/test_pid_adaptive.py
content: """Tests for pid_adaptive — PID controller for adaptive thresholds."""

import pytest

from pid_adaptive import PIDController, get_pid_state, get_or_create_controller


class TestPIDController:
    def test_compute_output(self):
        pid = PIDController(kp=0.1, ki=0.01, kd=0.05)
        result = pid.compute(1.0)
        assert isinstance(result, dict)
        assert result["output"] > 0

    def test_integral_accumulation(self):
        pid = PIDController(kp=0.0, ki=0.1, kd=0.0)
        pid.compute(1.0)
        result = pid.compute(1.0)
        assert result["output"] > 0

    def test_zero_error_zero_output(self):
        pid = PIDController(kp=0.1, ki=0.0, kd=0.0)
        result = pid.compute(0.0)
        assert result["output"] == 0.0

    def test_negative_error(self):
        pid = PIDController(kp=0.1, ki=0.0, kd=0.0)
        result = pid.compute(-1.0)
        assert result["output"] < 0

    def test_has_p_i_d_terms(self):
        pid = PIDController(kp=0.1, ki=0.01, kd=0.05)
        result = pid.compute(1.0)
        assert "p_term" in result
        assert "i_term" in result
        assert "d_term" in result

    def test_reset(self):
        pid = PIDController(kp=0.1, ki=0.1, kd=0.0)
        pid.compute(1.0)
        pid.compute(1.0)
        pid.reset()
        result = pid.compute(1.0)
        # After reset, integral should start fresh
        assert result["i_term"] < 0.2  # Much smaller than accumulated


class TestGetOrCreateController:
    def test_creates_new(self):
        pid_state = {"controllers": {}}
        ctrl = get_or_create_controller(pid_state, "test_group", kp=0.1)
        assert ctrl is not None
        assert "test_group" in pid_state["controllers"]

    def test_reuses_existing(self):
        pid_state = {"controllers": {}}
        ctrl1 = get_or_create_controller(pid_state, "test_group", kp=0.1)
        ctrl2 = get_or_create_controller(pid_state, "test_group", kp=0.1)
        # Controller state is persisted in pid_state
        assert "test_group" in pid_state["controllers"]


class TestGetPidState:
    def test_default(self):
        meta = {"version": "0.4.2"}
        state = get_pid_state(meta)
        assert isinstance(state, dict)
        assert "controllers" in state


# ─── New tests for uncovered functions ───────────────────────

from pid_adaptive import compute_error_signal, save_pid_state, update_thresholds


class TestComputeErrorSignal:
    def test_returns_dict(self):
        meta = {"memories": [], "l3_confirmations": [], "quality_gate_state": {}}
        result = compute_error_signal(meta)
        assert isinstance(result, dict)
        assert "error" in result
        assert "components" in result

    def test_with_l3_data(self):
        meta = {
            "memories": [],
            "l3_confirmations": [
                {"status": "confirmed"},
                {"status": "confirmed"},
                {"status": "degraded"},
                {"status": "pending"},
            ],
            "quality_gate_state": {"state": "NORMAL"},
        }
        result = compute_error_signal(meta)
        assert result["components"]["l3_coverage"]["rate"] == 0.75  # 3/4

    def test_critical_gate_state(self):
        meta = {
            "memories": [],
            "l3_confirmations": [],
            "quality_gate_state": {"state": "CRITICAL"},
        }
        result = compute_error_signal(meta)
        assert result["components"]["gate_factor"]["factor"] == 0.3


class TestSavePidState:
    def test_saves_state(self, tmp_path):
        import json
        meta = {"version": "0.4.2", "memories": []}
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        pid_state = get_pid_state(meta)
        pid_state["scene_thresholds"]["test"] = 0.7
        save_pid_state(p, meta, pid_state)
        with open(p) as f:
            saved = json.load(f)
        assert saved["pid_state"]["scene_thresholds"]["test"] == 0.7
        assert saved["pid_state"]["last_update"] is not None

    def test_updates_last_update(self, tmp_path):
        import json
        meta = {"version": "0.4.2", "memories": []}
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        pid_state = get_pid_state(meta)
        assert pid_state["last_update"] is None
        save_pid_state(p, meta, pid_state)
        with open(p) as f:
            saved = json.load(f)
        assert saved["pid_state"]["last_update"] is not None


class TestUpdateThresholds:
    def test_updates_active_memories(self, tmp_path):
        import json
        meta = {
            "version": "0.4.2",
            "memories": [
                {"id": "m1", "memory_type": "absorb", "status": "active"},
            ],
            "l3_confirmations": [],
            "quality_gate_state": {"state": "NORMAL"},
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        result = update_thresholds(p)
        assert result["status"] in ("updated", "no_change", "skipped")
        assert "error" in result

    def test_dry_run_no_save(self, tmp_path):
        import json
        meta = {
            "version": "0.4.2",
            "memories": [],
            "l3_confirmations": [],
            "quality_gate_state": {},
        }
        p = str(tmp_path / "meta.json")
        with open(p, "w") as f:
            json.dump(meta, f)
        result = update_thresholds(p, dry_run=True)
        assert result["dry_run"] is True
        # meta.json should not have pid_state written
        with open(p) as f:
            saved = json.load(f)
        assert "pid_state" not in saved


path: tests/test_quality_gate.py
content: """Tests for quality_gate — quality gate state machine and checks."""

import pytest

from quality_gate import (
    get_l2_config,
    load_normalized_meta,
    get_gate_state,
    save_gate_state,
    transition_state,
    check_layer,
    check_all_layers,
    compute_intervention_level,
    record_result,
    record_degraded_write,
    end_correction,
    check_l2_timeout,
    check_l1_audit_expired,
    get_l1_pending_count,
    enqueue_l1_pending,
    get_anomaly_mode,
    evaluate_quiet_degradation_state,
    check_inbox_backlog,
    check_routing_health,
    check_conflict_rate,
    run_health_checks,
    INTERVENTION_L0_SILENT,
    INTERVENTION_L1_NOTIFY,
    INTERVENTION_L2_PAUSE,
    INTERVENTION_L3_MANUAL,
    STATE_NORMAL,
    STATE_WARNING,
    STATE_CRITICAL,
    STATE_RECOVERING,
    WARNING_ANOMALY_N,
    RECOVERY_SWITCH_M,
    END_CORRECTION_THRESHOLD,
)


def _make_state(**overrides):
    defaults = {
        "state": "NORMAL",
        "anomaly_count": 0,
        "total_writes": 0,
        "total_failures": 0,
        "failure_rate": 0.0,
        "consecutive_clean": 0,
        "consecutive_failures": 0,
        "total_checks": 0,
        "history": [],
        "check_history": [],
        "warning_entered_at": None,
        "critical_entered_at": None,
        "recovering_entered_at": None,
    }
    defaults.update(overrides)
    return defaults


def _make_gate():
    return _make_state()


class TestGetGateState:
    def test_default_state(self):
        state = get_gate_state({"version": "0.4.2", "memories": []})
        assert state["state"] == "NORMAL"

    def test_existing_state(self):
        meta = {"version": "0.4.2", "memories": [],
                "quality_gate_state": {"state": "WARNING"}}
        state = get_gate_state(meta)
        assert state["state"] == "WARNING"


class TestTransitionState:
    def test_clean_stays_normal(self):
        state = _make_state()
        new_state, changed, info = transition_state(state, "clean")
        assert new_state["state"] == "NORMAL"

    def test_anomaly_normal_to_warning(self):
        state = _make_state()
        for _ in range(WARNING_ANOMALY_N):
            state, _, _ = transition_state(state, "anomaly")
        assert state["state"] == "WARNING"

    def test_anomaly_stays_normal_below_threshold(self):
        state = _make_state()
        for _ in range(WARNING_ANOMALY_N - 1):
            state, _, _ = transition_state(state, "anomaly")
        assert state["state"] == "NORMAL"

    def test_clean_warning_to_normal(self):
        # Need anomaly_count to drop to 0
        state = _make_state(state="WARNING", anomaly_count=1, consecutive_clean=0)
        new_state, changed, info = transition_state(state, "clean")
        assert new_state["state"] == "NORMAL"
        assert changed is True

    def test_clean_critical_stays_critical(self):
        state = _make_state(state="CRITICAL", anomaly_count=5)
        new_state, changed, info = transition_state(state, "clean")
        assert new_state["state"] == "CRITICAL"

    def test_clean_recovering_to_normal(self):
        state = _make_state(state="RECOVERING", consecutive_clean=RECOVERY_SWITCH_M)
        new_state, changed, info = transition_state(state, "clean")
        assert new_state["state"] == "NORMAL"

    def test_force_normal(self):
        state = _make_state(state="CRITICAL")
        new_state, changed, info = transition_state(state, "force_normal")
        assert new_state["state"] == "NORMAL"
        assert changed is True


class TestCheckLayer:
    def test_returns_tuple(self):
        result = check_layer("test", "security", {}, _make_gate())
        assert isinstance(result, tuple)
        assert len(result) == 3  # (passed, name, critical)

    def test_security_layer_passes(self):
        passed, name, critical = check_layer("normal content", "security", {}, _make_gate())
        assert passed is True

    def test_unknown_layer_passes(self):
        passed, name, critical = check_layer("test", "nonexistent", {}, _make_gate())
        assert passed is True


class TestCheckAllLayers:
    def test_all_pass(self):
        all_passed, results, _ = check_all_layers(
            "This is a normal content string for quality check",
            {"version": "0.4.2", "memories": []}
        )
        assert all_passed is True

    def test_empty_content(self):
        all_passed, results, _ = check_all_layers(
            "",
            {"version": "0.4.2", "memories": []}
        )
        assert isinstance(all_passed, bool)


class TestComputeInterventionLevel:
    def test_normal_state_l0(self):
        level, _, _ = compute_intervention_level(_make_state(), "ingest")
        assert level == INTERVENTION_L0_SILENT

    def test_warning_state_l1(self):
        level, _, _ = compute_intervention_level(_make_state(state="WARNING"), "ingest")
        assert level == INTERVENTION_L1_NOTIFY

    def test_critical_state_l2(self):
        level, _, _ = compute_intervention_level(_make_state(state="CRITICAL"), "ingest")
        assert level == INTERVENTION_L2_PAUSE


class TestGetL2Config:
    def test_default(self):
        meta = {"version": "0.4.2"}
        config = get_l2_config(meta)
        assert isinstance(config, dict)
        assert "queue_cap" in config
        assert config["queue_cap"] > 0


# ─── Helper: create a minimal meta.json in tmp_path/memory/ ───

def _setup_meta(tmp_path, **meta_overrides):
    """Create memory/meta.json in tmp_path with defaults + overrides."""
    import json
    from mg_utils import save_meta
    memory_dir = tmp_path / "memory"
    memory_dir.mkdir(parents=True, exist_ok=True)
    meta_path = memory_dir / "meta.json"
    meta = {"version": "0.4.2", "memories": []}
    meta.update(meta_overrides)
    save_meta(str(meta_path), meta)
    return str(meta_path), meta


class TestLoadNormalizedMeta:
    def test_load_basic(self, tmp_path):
        meta_path, _ = _setup_meta(tmp_path)
        meta = load_normalized_meta(meta_path)
        assert "version" in meta
        assert "memories" in meta

    def test_persist_changes(self, tmp_path):
        meta_path, _ = _setup_meta(tmp_path)
        # normalize may add defaults; persist=True should save
        meta = load_normalized_meta(meta_path, persist=True)
        # Reload and verify
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        assert reloaded is not None


class TestSaveGateState:
    def test_save_and_reload(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        state = get_gate_state(meta)
        state["anomaly_count"] = 5
        state["total_writes"] = 10
        save_gate_state(meta_path, meta, state)
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        qgs = reloaded.get("quality_gate_state", {})
        assert qgs["anomaly_count"] == 5
        assert qgs["total_writes"] == 10

    def test_preserves_unknown_fields(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        meta["quality_gate_state"] = {"state": "NORMAL", "custom_field": "keep_me"}
        state = get_gate_state(meta)
        save_gate_state(meta_path, meta, state)
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        assert reloaded["quality_gate_state"].get("custom_field") == "keep_me"

    def test_none_values_skip(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        meta["quality_gate_state"] = {"state": "NORMAL", "anomaly_count": 3}
        state = get_gate_state(meta)
        state["anomaly_count"] = None
        save_gate_state(meta_path, meta, state)
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        assert reloaded["quality_gate_state"]["anomaly_count"] == 3


class TestTransitionStateExtended:
    def test_anomaly_during_recovering(self):
        state = _make_state(state="RECOVERING", consecutive_clean=3)
        state, changed, info = transition_state(state, "anomaly")
        assert state["state"] == STATE_WARNING
        assert changed is True
        assert state["anomaly_count"] == 1  # reset counter

    def test_anomaly_in_warning_with_high_failure_rate(self):
        state = _make_state(state="WARNING", anomaly_count=5,
                            total_writes=1, total_failures=10)
        # failure_rate should be high enough to trigger CRITICAL
        state["failure_rate"] = 0.5
        state["state_entered_at"] = _now_iso()  # recent, so time condition not met
        # But anomaly trigger doesn't directly check failure_rate in WARNING;
        # _should_upgrade_to_critical does
        state, changed, info = transition_state(state, "anomaly")
        # With failure_rate 0.5 > 0.2, should upgrade
        assert state["state"] == STATE_CRITICAL
        assert changed is True

    def test_recovering_not_enough_clean(self):
        state = _make_state(state="RECOVERING", consecutive_clean=RECOVERY_SWITCH_M - 2)
        state, changed, info = transition_state(state, "clean")
        assert state["state"] == STATE_RECOVERING
        assert changed is False

    def test_clean_reduces_anomaly_count(self):
        state = _make_state(anomaly_count=2)
        state, _, _ = transition_state(state, "clean")
        assert state["anomaly_count"] == 1

    def test_force_normal_resets_counters(self):
        state = _make_state(state="CRITICAL", anomaly_count=10,
                            total_failures=20, failure_rate=0.8)
        state, changed, info = transition_state(state, "force_normal")
        assert state["anomaly_count"] == 0
        assert state["failure_rate"] == 0.0
        assert state["state"] == STATE_NORMAL

    def test_auto_check_upgrades_warning_to_critical(self):
        state = _make_state(state="WARNING", anomaly_count=3,
                            total_writes=1, total_failures=10)
        state["failure_rate"] = 0.5
        state, changed, info = transition_state(state, "auto_check")
        assert state["state"] == STATE_CRITICAL
        assert changed is True

    def test_history_records_transitions(self):
        state = _make_state()
        transition_state(state, "anomaly")
        transition_state(state, "anomaly")
        transition_state(state, "anomaly")
        # Now in WARNING
        assert state["state"] == STATE_WARNING
        assert len(state["history"]) >= 1
        assert state["history"][-1]["from"] == STATE_NORMAL
        assert state["history"][-1]["to"] == STATE_WARNING

    def test_clean_in_warning_resolves_anomaly_count_zero(self):
        state = _make_state(state="WARNING", anomaly_count=1, consecutive_clean=0)
        state, changed, info = transition_state(state, "clean")
        assert state["state"] == STATE_NORMAL
        assert changed is True


class TestCheckLayerExtended:
    def test_critical_blocks_all(self):
        gate = _make_state(state="CRITICAL")
        for layer in ["security", "dedup", "ingest", "decay"]:
            passed, reason, critical = check_layer("anything", layer, {}, gate)
            assert passed is False
            assert "critical:blocked" in reason

    def test_warning_skips_dedup(self):
        gate = _make_state(state="WARNING")
        passed, reason, bypass = check_layer("content", "dedup", {}, gate)
        assert passed is True
        assert bypass is True
        assert "skipped_warning" in reason

    def test_recovering_relaxes_dedup(self):
        gate = _make_state(state="RECOVERING")
        passed, reason, bypass = check_layer("content", "dedup", {}, gate)
        assert passed is True
        assert bypass is True

    def test_security_with_critical_rule(self):
        gate = _make_state()
        meta = {"security_rules": [{
            "id": "test-block", "severity": "critical",
            "pattern": r"forbidden_word", "description": "blocks forbidden",
        }]}
        passed, reason, _ = check_layer("this has forbidden_word", "security", meta, gate)
        assert passed is False

    def test_ingest_short_content_blocked(self):
        gate = _make_state()
        passed, reason, _ = check_layer("short", "ingest", {}, gate)
        assert passed is False
        assert "too_short" in reason

    def test_decay_always_passes(self):
        gate = _make_state()
        passed, reason, _ = check_layer("anything", "decay", {}, gate)
        assert passed is True

    def test_security_regex_error_fallback(self):
        gate = _make_state()
        meta = {"security_rules": [{
            "id": "bad-regex", "severity": "critical",
            "pattern": r"[invalid(", "description": "bad regex",
        }]}
        # Malformed regex should be skipped (not caught by literal fallback)
        passed, reason, _ = check_layer("[invalid(", "security", meta, gate)
        assert passed is True


class TestCheckAllLayersExtended:
    def test_critical_blocks_overall(self):
        gate = _make_state(state="CRITICAL")
        all_passed, results, degraded = check_all_layers("content", {}, gate)
        assert all_passed is False
        assert degraded is False  # critical is not degraded, it's blocked

    def test_warning_is_degraded(self):
        gate = _make_state(state="WARNING")
        all_passed, results, degraded = check_all_layers("a normal content string", {}, gate)
        assert degraded is True

    def test_recovering_is_degraded(self):
        gate = _make_state(state="RECOVERING")
        all_passed, results, degraded = check_all_layers("a normal content string", {}, gate)
        assert degraded is True

    def test_results_include_intervention_level(self):
        all_passed, results, _ = check_all_layers("content", {})
        assert "intervention_level" in results
        assert "notification_needed" in results


class TestRecordResult:
    def test_record_pass(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        gate = get_gate_state(meta)
        record_result(meta_path, meta, True, gate)
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        history = reloaded.get("quality_gate_state", {}).get("check_history", [])
        assert len(history) == 1
        assert history[0]["passed"] is True

    def test_record_fail(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        gate = get_gate_state(meta)
        record_result(meta_path, meta, False, gate)
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        history = reloaded.get("quality_gate_state", {}).get("check_history", [])
        assert len(history) == 1
        assert history[0]["passed"] is False

    def test_history_bounded(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        gate = get_gate_state(meta)
        # Add more than 100 entries
        for _ in range(120):
            record_result(meta_path, meta, True, gate)
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        history = reloaded.get("quality_gate_state", {}).get("check_history", [])
        assert len(history) <= 100


class TestRecordDegradedWrite:
    def test_warning_records_skipped_layers(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        gate = get_gate_state(meta)
        gate["state"] = STATE_WARNING
        record_degraded_write(meta_path, meta, "test content", gate)
        assert len(gate["degraded_writes"]) == 1
        entry = gate["degraded_writes"][0]
        assert "dedup" in entry["layers_skipped"]
        assert "ingest" in entry["layers_skipped"]

    def test_recovering_records_dedup_skipped(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        gate = get_gate_state(meta)
        gate["state"] = STATE_RECOVERING
        record_degraded_write(meta_path, meta, "test content", gate)
        entry = gate["degraded_writes"][0]
        assert "dedup" in entry["layers_skipped"]
        assert "ingest" not in entry["layers_skipped"]

    def test_content_preview_truncated(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        gate = get_gate_state(meta)
        gate["state"] = STATE_WARNING
        long_content = "x" * 200
        record_degraded_write(meta_path, meta, long_content, gate)
        entry = gate["degraded_writes"][0]
        assert len(entry["content_preview"]) <= 100


class TestEndCorrection:
    def test_flags_low_decay_memories(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path, memories=[
            {"id": "m1", "status": "active", "decay_score": 0.1, "content": "low decay"},
            {"id": "m2", "status": "active", "decay_score": 0.9, "content": "high decay"},
            {"id": "m3", "status": "archived", "decay_score": 0.05, "content": "archived"},
        ])
        flagged = end_correction(meta_path, meta)
        assert "m1" in flagged
        assert "m2" not in flagged
        assert "m3" not in flagged  # archived, not active

    def test_no_low_decay(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path, memories=[
            {"id": "m1", "status": "active", "decay_score": 0.8, "content": "ok"},
        ])
        flagged = end_correction(meta_path, meta)
        assert flagged == []

    def test_sets_quality_gate_fields(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path, memories=[
            {"id": "m1", "status": "active", "decay_score": 0.1, "content": "low"},
        ])
        end_correction(meta_path, meta)
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        mem = reloaded["memories"][0]
        assert "quality_gate" in mem
        assert mem["quality_gate"]["gate_mode"] == "anomaly_correction"


class TestComputeInterventionLevelExtended:
    def test_normal_delete_l0(self):
        level, _, _ = compute_intervention_level(_make_state(), "delete")
        assert level == INTERVENTION_L0_SILENT

    def test_l3_high_anomaly_delete(self):
        level, notify, block = compute_intervention_level(
            _make_state(anomaly_count=10), "delete")
        assert level == INTERVENTION_L3_MANUAL
        assert notify is True
        assert block is True

    def test_l3_not_in_critical(self):
        # In CRITICAL state, destructive actions (delete/modify) still trigger L3
        # because human confirmation is needed even more urgently when the system
        # is in a degraded state. Only ingest falls through to L2 queueing.
        level, _, _ = compute_intervention_level(
            _make_state(state="CRITICAL", anomaly_count=20), "delete")
        assert level == INTERVENTION_L3_MANUAL

    def test_l2_for_ingest_in_critical(self):
        # In CRITICAL, non-destructive actions (ingest) still go to L2 queue
        level, _, _ = compute_intervention_level(
            _make_state(state="CRITICAL", anomaly_count=20), "ingest")
        assert level == INTERVENTION_L2_PAUSE

    def test_warning_modify_l1(self):
        level, notify, block = compute_intervention_level(
            _make_state(state="WARNING"), "modify")
        assert level == INTERVENTION_L1_NOTIFY
        assert notify is True
        assert block is False

    def test_recovering_ingest_l1(self):
        level, _, _ = compute_intervention_level(
            _make_state(state="RECOVERING"), "ingest")
        assert level == INTERVENTION_L1_NOTIFY

    def test_anomaly_count_3_triggers_l1(self):
        # anomaly_count >= 3 but state=NORMAL → only for delete/modify, not ingest
        level, _, _ = compute_intervention_level(
            _make_state(anomaly_count=3), "delete")
        assert level == INTERVENTION_L1_NOTIFY

    def test_configurable_l3_threshold(self):
        meta = {"decay_config": {"l3_anomaly_threshold": 20}}
        level, _, _ = compute_intervention_level(
            _make_state(anomaly_count=10), "delete", meta)
        # 10 < 20, so not L3
        assert level != INTERVENTION_L3_MANUAL


class TestCheckL2Timeout:
    def test_no_l2_entry(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        result = check_l2_timeout(meta_path)
        assert result["timed_out"] is False

    def test_recent_entry_no_timeout(self, tmp_path):
        from datetime import datetime, timedelta, timezone
        from mg_utils import save_meta
        meta_path, meta = _setup_meta(tmp_path)
        now = datetime.now(timezone(timedelta(hours=8)))
        meta["quality_gate_state"] = {"state": STATE_CRITICAL, "l2_entered_at": now.isoformat()}
        save_meta(meta_path, meta)
        result = check_l2_timeout(meta_path)
        assert result["timed_out"] is False

    def test_old_entry_timeout(self, tmp_path):
        from datetime import datetime, timedelta, timezone
        from mg_utils import save_meta
        meta_path, meta = _setup_meta(tmp_path)
        CST = timezone(timedelta(hours=8))
        old = datetime.now(CST) - timedelta(minutes=60)
        meta["quality_gate_state"] = {
            "state": STATE_CRITICAL,
            "l2_entered_at": old.isoformat(),
            "l2_write_queue": [],
        }
        save_meta(meta_path, meta)
        result = check_l2_timeout(meta_path)
        assert result["timed_out"] is True


class TestCheckL1AuditExpired:
    def test_no_pending(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        result = check_l1_audit_expired(meta_path)
        assert result["expired"] == 0

    def test_active_entry_not_expired(self, tmp_path):
        from mg_utils import save_meta
        from datetime import datetime, timedelta, timezone
        CST = timezone(timedelta(hours=8))
        now = datetime.now(CST)
        future = (now + timedelta(minutes=30)).isoformat()
        meta_path, meta = _setup_meta(tmp_path)
        meta["l1_pending"] = [{
            "id": "l1_test", "status": "executed",
            "audit_until": future, "reviewed": False,
        }]
        save_meta(meta_path, meta)
        result = check_l1_audit_expired(meta_path)
        assert result["expired"] == 0

    def test_expired_entry_resolved(self, tmp_path):
        from mg_utils import save_meta
        from datetime import datetime, timedelta, timezone
        CST = timezone(timedelta(hours=8))
        now = datetime.now(CST)
        past = (now - timedelta(minutes=60)).isoformat()
        meta_path, meta = _setup_meta(tmp_path)
        meta["l1_pending"] = [{
            "id": "l1_old", "status": "executed",
            "audit_until": past, "reviewed": False,
        }]
        save_meta(meta_path, meta)
        result = check_l1_audit_expired(meta_path)
        assert result["expired"] == 1


class TestGetL1PendingCount:
    def test_empty(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        result = get_l1_pending_count(meta_path)
        assert result["active"] == 0
        assert result["total"] == 0

    def test_with_active_entries(self, tmp_path):
        from mg_utils import save_meta
        meta_path, meta = _setup_meta(tmp_path)
        meta["l1_pending"] = [
            {"id": "l1_1", "status": "executed"},
            {"id": "l1_2", "status": "executed"},
            {"id": "l1_3", "status": "audit_expired"},
        ]
        save_meta(meta_path, meta)
        result = get_l1_pending_count(meta_path)
        assert result["active"] == 2
        assert result["total"] == 3


class TestEnqueueL1Pending:
    def test_basic_enqueue(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        entry = enqueue_l1_pending(meta_path, {"action": "ingest", "fields": {"content": "test"}},
                                   reason="test enqueue")
        assert entry["status"] == "executed"
        assert entry["action"] == "ingest"
        assert entry["audit_until"] is not None

    def test_persists_to_meta(self, tmp_path):
        meta_path, meta = _setup_meta(tmp_path)
        enqueue_l1_pending(meta_path, {"action": "delete", "fields": {}}, reason="test")
        from mg_utils import load_meta
        reloaded = load_meta(meta_path)
        assert len(reloaded.get("l1_pending", [])) == 1


class TestGetAnomalyMode:
    def test_default_mode(self):
        mode_info = get_anomaly_mode({"version": "0.4.2"})
        assert mode_info["mode"] == "normal_decay"
        assert "config" in mode_info

    def test_config_structure(self):
        mode_info = get_anomaly_mode({"version": "0.4.2"})
        cfg = mode_info["config"]
        assert "failure_rate_threshold" in cfg
        assert "failure_count_threshold" in cfg
        assert "recovery_consecutive_clean" in cfg
        assert "auto_heal_hours" in cfg


class TestEvaluateQuietDegradationState:
    def test_no_samples(self):
        result = evaluate_quiet_degradation_state({"version": "0.4.2", "quality_gate_state": {}})
        # Should return a dict with status info
        assert isinstance(result, dict)

    def test_with_samples(self):
        from datetime import datetime, timedelta, timezone
        CST = timezone(timedelta(hours=8))
        now = datetime.now(CST)
        samples = []
        for i in range(10):
            samples.append({
                "timestamp": (now - timedelta(days=i)).isoformat(),
                "archived_count": 2,
                "total_count": 10,
                "rate": 0.2,
            })
        result = evaluate_quiet_degradation_state({
            "version": "0.4.2",
            "quality_gate_state": {"archive_rate_history": samples},
        })
        assert isinstance(result, dict)


class TestCheckInboxBacklog:
    def test_empty_memories(self):
        result = check_inbox_backlog({"memories": []})
        assert result["status"] == "healthy"
        assert result["inbox_count"] == 0

    def test_inbox_entries(self):
        result = check_inbox_backlog({"memories": [
            {"id": "m1", "status": "_inbox"},
            {"id": "m2", "status": "_inbox"},
            {"id": "m3", "status": "active"},
        ]})
        assert result["inbox_count"] == 2

    def test_critical_backlog(self):
        memories = [{"id": f"m{i}", "status": "_inbox"} for i in range(35)]
        result = check_inbox_backlog({"memories": memories})
        assert result["status"] == "critical"

    def test_warning_backlog(self):
        memories = [{"id": f"m{i}", "status": "_inbox"} for i in range(15)]
        result = check_inbox_backlog({"memories": memories})
        assert result["status"] == "warning"

    def test_overdue_needs_review(self):
        from datetime import datetime, timedelta, timezone
        CST = timezone(timedelta(hours=8))
        old = (datetime.now(CST) - timedelta(days=10)).isoformat()
        result = check_inbox_backlog({"memories": [
            {"id": "m1", "status": "active", "needs_review": True,
             "needs_review_since": old, "needs_review_timeout": 7},
        ]})
        assert result["overdue_count"] == 1


class TestCheckRoutingHealth:
    def test_no_routing_data(self):
        result = check_routing_health({"routing_log": []})
        assert result["status"] == "healthy"
        assert result["total_routes"] == 0

    def test_balanced_routing(self):
        routing_log = [
            {"selected": [("cat_a", 0.9)]},
            {"selected": [("cat_b", 0.8)]},
            {"selected": [("cat_a", 0.7)]},
            {"selected": [("cat_c", 0.6)]},
        ]
        result = check_routing_health({"routing_log": routing_log})
        assert result["status"] == "healthy"
        assert result["concentration_ratio"] < 0.8

    def test_concentrated_routing(self):
        routing_log = [{"selected": [("cat_a", 0.9)]} for _ in range(20)]
        result = check_routing_health({"routing_log": routing_log})
        assert result["status"] == "warning"
        assert len(result["alerts"]) > 0


class TestCheckConflictRate:
    def test_no_conflicts(self):
        result = check_conflict_rate({"conflict_log": [], "quality_gate_state": {}})
        assert result["status"] == "healthy"
        assert result["conflict_rate"] == 0.0

    def test_with_recent_conflicts(self):
        from datetime import datetime, timedelta, timezone
        CST = timezone(timedelta(hours=8))
        now = datetime.now(CST)
        conflict_log = [{"timestamp": now.isoformat()} for _ in range(5)]
        gate = {"write_counters": {"total_writes_24h": 100}}
        result = check_conflict_rate({"conflict_log": conflict_log, "quality_gate_state": gate})
        assert result["total_conflicts_24h"] == 5
        assert result["conflict_rate"] == 0.05

    def test_critical_conflict_rate(self):
        from datetime import datetime, timedelta, timezone
        CST = timezone(timedelta(hours=8))
        now = datetime.now(CST)
        conflict_log = [{"timestamp": now.isoformat()} for _ in range(10)]
        gate = {"write_counters": {"total_writes_24h": 100}}
        result = check_conflict_rate({"conflict_log": conflict_log, "quality_gate_state": gate})
        assert result["status"] == "critical"


class TestRunHealthChecks:
    def test_all_healthy(self):
        result = run_health_checks({"memories": [], "routing_log": [], "conflict_log": []})
        assert result["overall_status"] == "healthy"
        assert "inbox" in result
        assert "routing" in result
        assert "conflicts" in result

    def test_overall_is_worst(self):
        result = run_health_checks({
            "memories": [{"id": f"m{i}", "status": "_inbox"} for i in range(35)],
            "routing_log": [],
            "conflict_log": [],
        })
        assert result["overall_status"] == "critical"

    def test_alert_count(self):
        result = run_health_checks({"memories": [], "routing_log": [], "conflict_log": []})
        # info alerts should be filtered out
        assert isinstance(result["alert_count"], int)

    def test_checked_at_present(self):
        result = run_health_checks({"memories": [], "routing_log": [], "conflict_log": []})
        assert "checked_at" in result
        assert result["checked_at"] is not None


def _now_iso():
    """Helper to get current ISO timestamp."""
    from datetime import datetime, timezone, timedelta
    return datetime.now(timezone(timedelta(hours=8))).isoformat()


path: scripts/dataflow_report.py
content: #!/usr/bin/env python3
"""Print module telemetry and dataflow break-candidate report."""

import argparse
import os

from mg_events.telemetry import build_report


def main(argv=None):
    parser = argparse.ArgumentParser(description="memory-guardian dataflow report")
    parser.add_argument("--workspace", required=True, help="Workspace path")
    args = parser.parse_args(argv)

    report = build_report(args.workspace)
    print("MEMORY GUARDIAN — Dataflow Report")
    print(f"Generated: {report['generated_at']}")

    modules = report["modules"]
    if not modules:
        print("No telemetry recorded yet.")
        return 0

    for name in sorted(modules):
        stats = modules[name]
        flag = " BREAK" if stats.get("pipeline_break_candidate") else ""
        print(
            f"{name}: hits={stats['hits']} misses={stats['misses']} "
            f"input={stats['input_total']} output={stats['output_total']}{flag}"
        )

    if report["break_candidates"]:
        print("Pipeline break candidates: " + ", ".join(report["break_candidates"]))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/mg_state/__init__.py
content: """State helpers for quality gate transitions and degradation rules."""

from .quality_gate_rules import evaluate_quiet_degradation
from .transition_engine import update_anomaly_mode_state

__all__ = [
    "evaluate_quiet_degradation",
    "update_anomaly_mode_state",
]


path: scripts/mg_repo/__init__.py
content: """Repository helpers for memory-guardian."""

from .meta_json_repository import MetaJsonRepository

__all__ = ["MetaJsonRepository"]


path: scripts/mg_events/__init__.py
content: """Telemetry/event helpers for memory-guardian."""

from .telemetry import build_report, record_module_run, telemetry_path

__all__ = ["build_report", "record_module_run", "telemetry_path"]


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)