mnemo-claude 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. mnemo/__init__.py +1 -0
  2. mnemo/__main__.py +4 -0
  3. mnemo/cli/__init__.py +31 -0
  4. mnemo/cli/_helpers/__init__.py +80 -0
  5. mnemo/cli/commands/__init__.py +25 -0
  6. mnemo/cli/commands/briefing.py +35 -0
  7. mnemo/cli/commands/dedup_rules.py +44 -0
  8. mnemo/cli/commands/disable_rule.py +62 -0
  9. mnemo/cli/commands/doctor.py +124 -0
  10. mnemo/cli/commands/doctor_checks/__init__.py +30 -0
  11. mnemo/cli/commands/doctor_checks/activation.py +211 -0
  12. mnemo/cli/commands/doctor_checks/fidelity.py +38 -0
  13. mnemo/cli/commands/doctor_checks/misc.py +92 -0
  14. mnemo/cli/commands/doctor_checks/orphan_worktree_briefings.py +85 -0
  15. mnemo/cli/commands/doctor_checks/reflex.py +189 -0
  16. mnemo/cli/commands/doctor_checks/rules.py +179 -0
  17. mnemo/cli/commands/extract.py +88 -0
  18. mnemo/cli/commands/init.py +204 -0
  19. mnemo/cli/commands/list_enforced.py +56 -0
  20. mnemo/cli/commands/migrate_worktree_briefings.py +90 -0
  21. mnemo/cli/commands/misc.py +87 -0
  22. mnemo/cli/commands/recall.py +79 -0
  23. mnemo/cli/commands/status.py +224 -0
  24. mnemo/cli/commands/statusline.py +34 -0
  25. mnemo/cli/commands/telemetry.py +24 -0
  26. mnemo/cli/parser.py +102 -0
  27. mnemo/cli/runtime.py +47 -0
  28. mnemo/core/__init__.py +0 -0
  29. mnemo/core/agent.py +111 -0
  30. mnemo/core/briefing.py +234 -0
  31. mnemo/core/config.py +138 -0
  32. mnemo/core/dashboard.py +200 -0
  33. mnemo/core/dedup_rules.py +169 -0
  34. mnemo/core/errors.py +126 -0
  35. mnemo/core/extract/__init__.py +542 -0
  36. mnemo/core/extract/inbox/__init__.py +75 -0
  37. mnemo/core/extract/inbox/apply.py +160 -0
  38. mnemo/core/extract/inbox/branches/__init__.py +14 -0
  39. mnemo/core/extract/inbox/branches/auto_promoted.py +163 -0
  40. mnemo/core/extract/inbox/branches/inbox_flow.py +180 -0
  41. mnemo/core/extract/inbox/branches/upgrade.py +41 -0
  42. mnemo/core/extract/inbox/dedup.py +218 -0
  43. mnemo/core/extract/inbox/io.py +86 -0
  44. mnemo/core/extract/inbox/paths.py +66 -0
  45. mnemo/core/extract/inbox/rendering.py +157 -0
  46. mnemo/core/extract/inbox/state_io.py +94 -0
  47. mnemo/core/extract/inbox/types.py +46 -0
  48. mnemo/core/extract/promote.py +100 -0
  49. mnemo/core/extract/prompts/__init__.py +42 -0
  50. mnemo/core/extract/prompts/encoding.py +30 -0
  51. mnemo/core/extract/prompts/render.py +127 -0
  52. mnemo/core/extract/prompts/templates/__init__.py +7 -0
  53. mnemo/core/extract/prompts/templates/briefing.py +50 -0
  54. mnemo/core/extract/prompts/templates/few_shot_feedback.py +167 -0
  55. mnemo/core/extract/prompts/templates/few_shot_simple.py +44 -0
  56. mnemo/core/extract/prompts/templates/schema.py +48 -0
  57. mnemo/core/extract/prompts/templates/system_feedback.py +105 -0
  58. mnemo/core/extract/prompts/templates/system_simple.py +33 -0
  59. mnemo/core/extract/prompts/vault_tags.py +33 -0
  60. mnemo/core/extract/scanner.py +215 -0
  61. mnemo/core/filters.py +229 -0
  62. mnemo/core/llm.py +184 -0
  63. mnemo/core/locks.py +44 -0
  64. mnemo/core/log_utils.py +15 -0
  65. mnemo/core/log_writer.py +70 -0
  66. mnemo/core/mcp/__init__.py +6 -0
  67. mnemo/core/mcp/access_log.py +113 -0
  68. mnemo/core/mcp/access_log_summary.py +220 -0
  69. mnemo/core/mcp/recall.py +346 -0
  70. mnemo/core/mcp/server.py +231 -0
  71. mnemo/core/mcp/session_state.py +212 -0
  72. mnemo/core/mcp/tools.py +291 -0
  73. mnemo/core/mirror.py +103 -0
  74. mnemo/core/paths.py +44 -0
  75. mnemo/core/pricing.py +36 -0
  76. mnemo/core/reflex/__init__.py +1 -0
  77. mnemo/core/reflex/bm25.py +100 -0
  78. mnemo/core/reflex/gates.py +74 -0
  79. mnemo/core/reflex/index.py +159 -0
  80. mnemo/core/reflex/stopwords.py +42 -0
  81. mnemo/core/reflex/tokenizer.py +44 -0
  82. mnemo/core/rule_activation/__init__.py +53 -0
  83. mnemo/core/rule_activation/activity_log.py +85 -0
  84. mnemo/core/rule_activation/globs.py +113 -0
  85. mnemo/core/rule_activation/index.py +316 -0
  86. mnemo/core/rule_activation/matching.py +232 -0
  87. mnemo/core/rule_activation/parsing.py +263 -0
  88. mnemo/core/session.py +66 -0
  89. mnemo/core/text_utils.py +27 -0
  90. mnemo/core/transcript.py +54 -0
  91. mnemo/hooks/__init__.py +0 -0
  92. mnemo/hooks/pre_tool_use.py +161 -0
  93. mnemo/hooks/session_end.py +304 -0
  94. mnemo/hooks/session_start.py +241 -0
  95. mnemo/hooks/user_prompt_submit.py +209 -0
  96. mnemo/install/__init__.py +0 -0
  97. mnemo/install/preflight.py +105 -0
  98. mnemo/install/scaffold.py +48 -0
  99. mnemo/install/settings.py +451 -0
  100. mnemo/statusline.py +319 -0
  101. mnemo/templates/HOME.md +53 -0
  102. mnemo/templates/README.md +34 -0
  103. mnemo/templates/__init__.py +0 -0
  104. mnemo/templates/graph-dark-gold.css +16 -0
  105. mnemo/templates/mnemo.config.json +10 -0
  106. mnemo_claude-0.12.0.dist-info/METADATA +542 -0
  107. mnemo_claude-0.12.0.dist-info/RECORD +111 -0
  108. mnemo_claude-0.12.0.dist-info/WHEEL +5 -0
  109. mnemo_claude-0.12.0.dist-info/entry_points.txt +2 -0
  110. mnemo_claude-0.12.0.dist-info/licenses/LICENSE +21 -0
  111. mnemo_claude-0.12.0.dist-info/top_level.txt +1 -0
mnemo/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
mnemo/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from mnemo.cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ raise SystemExit(main())
mnemo/cli/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Backwards-compat shim for ``mnemo.cli`` (v0.9 PR H).
2
+
3
+ The 1294-line ``cli.py`` was split into a package in v0.9 PR H. This
4
+ shim re-exports the names the public API surface test pins
5
+ (``main``, ``COMMANDS``, ``_resolve_vault``), the symbols every
6
+ ``cmd_*`` looks up at call-time so monkeypatch.setattr against
7
+ ``mnemo.cli.<name>`` propagates (``_resolve_vault``, ``_run_open``),
8
+ and the three ``_doctor_check_reflex_*`` helpers
9
+ ``test_cli_status_doctor_reflex`` reads as attributes of
10
+ ``mnemo.cli``. Importing the ``commands`` submodule also triggers
11
+ each ``@command`` decorator so the ``COMMANDS`` registry is
12
+ populated by package-load time.
13
+
14
+ New code should import from concrete submodules
15
+ (``mnemo.cli.runtime``, ``mnemo.cli.commands.<name>``,
16
+ ``mnemo.cli.commands.doctor_checks.<concern>``).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from mnemo.cli import commands # noqa: F401 — trigger @command registration
21
+ from mnemo.cli.commands.doctor_checks.reflex import ( # noqa: F401
22
+ _doctor_check_reflex_bilingual_gap,
23
+ _doctor_check_reflex_index,
24
+ _doctor_check_reflex_session_cap_hits,
25
+ )
26
+ from mnemo.cli.parser import COMMANDS # noqa: F401
27
+ from mnemo.cli.runtime import ( # noqa: F401
28
+ _resolve_vault,
29
+ _run_open,
30
+ main,
31
+ )
@@ -0,0 +1,80 @@
1
+ """Small CLI-local helpers used by the status / doctor commands.
2
+
3
+ Absorbs the v0.9 PR-A flat module ``mnemo.cli_helpers`` (81L) into the
4
+ package layout introduced by PR H. Pure functions only — no argparse
5
+ wiring — kept independently importable so the helpers stay
6
+ unit-testable without booting the full CLI.
7
+
8
+ Zero behavior change vs. the previous ``mnemo.cli_helpers`` module.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+
17
+ def _read_jsonl_tail(path: Path, max_lines: int) -> list[dict]:
18
+ """Return the last *max_lines* decoded JSON objects from *path*.
19
+
20
+ Unified implementation behind :func:`_read_denial_log_tail` and
21
+ :func:`_read_enrichment_log_tail`. Blank lines and malformed JSON
22
+ lines are skipped silently. Any read error (missing file, decode
23
+ failure, permission) yields an empty list — callers treat the log
24
+ as advisory data, never a hard dependency.
25
+ """
26
+ try:
27
+ if not path.exists():
28
+ return []
29
+ text = path.read_text(encoding="utf-8", errors="replace")
30
+ lines = text.splitlines()
31
+ if len(lines) > max_lines:
32
+ lines = lines[-max_lines:]
33
+ entries: list[dict] = []
34
+ for line in lines:
35
+ line = line.strip()
36
+ if not line:
37
+ continue
38
+ try:
39
+ entries.append(json.loads(line))
40
+ except json.JSONDecodeError:
41
+ continue
42
+ return entries
43
+ except Exception:
44
+ return []
45
+
46
+
47
+ def _read_denial_log_tail(vault: Path, max_lines: int = 1000) -> list[dict]:
48
+ """Read last *max_lines* from denial-log.jsonl. Returns [] on any error."""
49
+ return _read_jsonl_tail(vault / ".mnemo" / "denial-log.jsonl", max_lines)
50
+
51
+
52
+ def _read_enrichment_log_tail(vault: Path, max_lines: int = 1000) -> list[dict]:
53
+ """Read last *max_lines* from enrichment-log.jsonl. Returns [] on any error."""
54
+ return _read_jsonl_tail(vault / ".mnemo" / "enrichment-log.jsonl", max_lines)
55
+
56
+
57
+ def _count_today_denial_entries(entries: list[dict]) -> int:
58
+ """Count entries whose timestamp starts with today's date (UTC)."""
59
+ today_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
60
+ return sum(
61
+ 1 for e in entries
62
+ if isinstance(e.get("timestamp"), str) and e["timestamp"].startswith(today_prefix)
63
+ )
64
+
65
+
66
+ def _synthesize_path_for_glob(glob_pattern: str) -> str | None:
67
+ """Produce a concrete file path that should match the glob, or None.
68
+
69
+ Deterministic replacements:
70
+ ``**/`` -> ``a/`` (match-zero-or-more-segments case)
71
+ ``**`` -> ``a`` (trailing double-star)
72
+ ``*`` -> ``sample`` (single segment)
73
+ Returns None when the glob contains character classes or ``?`` — those
74
+ cannot be safely synthesized without guessing which characters the author
75
+ intended to match.
76
+ """
77
+ if "?" in glob_pattern or "[" in glob_pattern:
78
+ return None
79
+ out = glob_pattern.replace("**/", "a/").replace("**", "a").replace("*", "sample")
80
+ return out or None
@@ -0,0 +1,25 @@
1
+ """Command sub-package — one module per CLI command.
2
+
3
+ Each module decorates its handler with ``@command("<name>")`` from
4
+ :mod:`mnemo.cli.parser`. The decorator runs at import time, so we
5
+ must import every command module here for the registry to be
6
+ populated by the time :func:`mnemo.cli.runtime.main` looks up a
7
+ handler.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from mnemo.cli.commands import ( # noqa: F401 — trigger @command registration
12
+ briefing,
13
+ dedup_rules,
14
+ disable_rule,
15
+ doctor,
16
+ extract,
17
+ init,
18
+ list_enforced,
19
+ migrate_worktree_briefings,
20
+ misc,
21
+ recall,
22
+ statusline,
23
+ status,
24
+ telemetry,
25
+ )
@@ -0,0 +1,35 @@
1
+ """``mnemo briefing`` — hidden CLI hook invoked by session_end's detached spawn."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+
7
+ from mnemo.cli.parser import command
8
+
9
+
10
+ @command("briefing")
11
+ def cmd_briefing(args: argparse.Namespace) -> int:
12
+ """Hidden CLI entry point: `mnemo briefing <jsonl_path> <agent>`.
13
+
14
+ Invoked by session_end's detached spawn. Fire-and-forget: errors are
15
+ logged to ~/.errors.log under the vault but never propagated.
16
+ """
17
+ import contextlib
18
+ import os
19
+ from mnemo.core import briefing as briefing_mod, config as cfg_mod, errors as err_mod, paths
20
+
21
+ cfg = cfg_mod.load_config()
22
+ vault_root = paths.vault_root(cfg)
23
+ devnull = open(os.devnull, "w")
24
+ try:
25
+ with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
26
+ try:
27
+ briefing_mod.generate_session_briefing(
28
+ Path(args.jsonl_path), args.agent, cfg,
29
+ )
30
+ except Exception as exc:
31
+ err_mod.log_error(vault_root, "briefing.cli", exc)
32
+ return 1
33
+ finally:
34
+ devnull.close()
35
+ return 0
@@ -0,0 +1,44 @@
1
+ """`mnemo dedup-rules` — consolidate shared/*.md files sharing the same `name:`.
2
+
3
+ Dry-run by default (like `migrate-worktree-briefings`). Use `--apply` to
4
+ execute the plan: canonical = most sources[] (tie → newer extracted_at);
5
+ duplicates deleted; sources[] + frontmatter project(s) unioned on canonical.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+
11
+ from mnemo.cli.parser import command
12
+
13
+
14
+ @command("dedup-rules")
15
+ def cmd_dedup_rules(args: argparse.Namespace) -> int:
16
+ from mnemo import cli
17
+ from mnemo.core.dedup_rules import plan_dedup
18
+ from mnemo.core.filters import parse_frontmatter
19
+
20
+ vault = cli._resolve_vault()
21
+ plan = plan_dedup(vault)
22
+
23
+ if not plan.groups:
24
+ print("no duplicates found")
25
+ return 0
26
+
27
+ print(f"{len(plan.groups)} group(s) with duplicate names:\n")
28
+ for g in plan.groups:
29
+ canon_fm = parse_frontmatter(g.canonical.read_text(encoding="utf-8"))
30
+ name = canon_fm.get("name", "")
31
+ canon_rel = g.canonical.relative_to(vault)
32
+ dup_rels = ", ".join(p.name for p in g.duplicates)
33
+ print(f" '{name}'")
34
+ print(f" keep: {canon_rel}")
35
+ print(f" delete: {dup_rels}")
36
+ print(f" merged_sources: {len(g.merged_sources)} projects: {g.merged_projects}")
37
+
38
+ if not getattr(args, "apply", False):
39
+ print("\n(dry-run — pass --apply to execute)")
40
+ return 0
41
+
42
+ plan.apply()
43
+ print("\napplied.")
44
+ return 0
@@ -0,0 +1,62 @@
1
+ """`mnemo disable-rule <slug>` — flip runtime: false on a rule's frontmatter."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from mnemo.cli.parser import command
9
+ from mnemo.core import config, paths
10
+ from mnemo.core.filters import derive_rule_slug, parse_frontmatter
11
+
12
+
13
+ def _find_rule_file(vault_root: Path, slug: str) -> Path | None:
14
+ shared = vault_root / "shared"
15
+ if not shared.is_dir():
16
+ return None
17
+ for md in shared.rglob("*.md"):
18
+ # Match by filesystem stem first (most reliable for slug-as-filename)
19
+ if md.stem == slug:
20
+ return md
21
+ # Also match by derive_rule_slug in case frontmatter slug/name matches
22
+ try:
23
+ text = md.read_text()
24
+ except OSError:
25
+ continue
26
+ fm = parse_frontmatter(text)
27
+ if fm and derive_rule_slug(fm, md.stem) == slug:
28
+ return md
29
+ return None
30
+
31
+
32
+ def run_disable_rule(vault_root: Path, *, slug: str) -> int:
33
+ md = _find_rule_file(vault_root, slug)
34
+ if md is None:
35
+ print(f"error: rule not found for slug {slug!r}", file=sys.stderr)
36
+ return 2
37
+ text = md.read_text()
38
+ if not text.startswith("---\n"):
39
+ print(f"error: {md} has no frontmatter", file=sys.stderr)
40
+ return 2
41
+ end = text.find("\n---\n", 4)
42
+ if end == -1:
43
+ print(f"error: {md} frontmatter not closed", file=sys.stderr)
44
+ return 2
45
+ fm_block = text[4:end]
46
+ body = text[end + 5:]
47
+ if "\nruntime: false" in "\n" + fm_block or fm_block.startswith("runtime: false"):
48
+ print(f"already disabled: {md.relative_to(vault_root)}")
49
+ return 0
50
+ fm_lines = [ln for ln in fm_block.splitlines() if ln.strip() != "runtime: true"]
51
+ fm_lines.append("runtime: false")
52
+ new_text = "---\n" + "\n".join(fm_lines) + "\n---\n" + body
53
+ md.write_text(new_text)
54
+ print(f"disabled: {md.relative_to(vault_root)}")
55
+ return 0
56
+
57
+
58
+ @command("disable-rule")
59
+ def _cmd(ns: argparse.Namespace) -> int:
60
+ cfg = config.load_config()
61
+ vault = paths.vault_root(cfg)
62
+ return run_disable_rule(vault, slug=ns.slug)
@@ -0,0 +1,124 @@
1
+ """``mnemo doctor`` — preflight + a registry of doctor checks.
2
+
3
+ PR H of the v0.9 refactor roadmap converted ``cmd_doctor`` from a
4
+ hardcoded and-chain of 11 calls into an OCP-compliant
5
+ ``DOCTOR_CHECKS: list[tuple[name, callable]]`` registry. Adding a new
6
+ check is now a new row, not an edit to ``cmd_doctor``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ from mnemo.cli.commands.doctor_checks import (
15
+ activation,
16
+ fidelity,
17
+ misc as doctor_misc,
18
+ orphan_worktree_briefings,
19
+ reflex,
20
+ rules,
21
+ )
22
+ from mnemo.cli.parser import command
23
+
24
+ # Registry: (display_name, check_fn). Order matters — it matches the
25
+ # original cmd_doctor invocation order in the v0.8.x cli.py monolith
26
+ # (lines 402-413). ``_doctor_check_universal_promotion`` always returns
27
+ # True today (advisory-only), so the ``if ok is False`` guard below is
28
+ # robust to either bool or None return values.
29
+ DOCTOR_CHECKS: list[tuple[str, Callable[[Path], bool]]] = [
30
+ ("auto_brain", doctor_misc._doctor_check_auto_brain),
31
+ ("legacy_wiki_dirs", doctor_misc._doctor_check_legacy_wiki_dirs),
32
+ ("statusline_drift", reflex._doctor_check_statusline_drift),
33
+ ("activation", activation._doctor_check_activation),
34
+ ("zero_hit", fidelity._doctor_check_zero_hit),
35
+ ("activation_fidelity", activation._doctor_check_activation_fidelity),
36
+ ("rule_integrity", rules._doctor_check_rule_integrity),
37
+ ("reflex_index", reflex._doctor_check_reflex_index),
38
+ ("reflex_session_cap", reflex._doctor_check_reflex_session_cap_hits),
39
+ ("reflex_bilingual", reflex._doctor_check_reflex_bilingual_gap),
40
+ ("orphan_worktree_briefings", orphan_worktree_briefings._doctor_check_orphan_worktree_briefings),
41
+ ("universal_promotion", rules._doctor_check_universal_promotion),
42
+ ("bare_deny_command", rules._doctor_check_bare_deny_command),
43
+ ("stripped_enforce", rules._doctor_check_stripped_enforce),
44
+ ]
45
+
46
+
47
+ def _detect_install_scope() -> str:
48
+ """Return 'project', 'global', 'both', or 'none' based on settings files present."""
49
+ import os
50
+ project = (Path.cwd() / ".claude" / "settings.json").exists()
51
+ global_ = Path(os.path.expanduser("~/.claude/settings.json")).exists()
52
+ if project and global_:
53
+ return "both"
54
+ if project:
55
+ return "project"
56
+ if global_:
57
+ return "global"
58
+ return "none"
59
+
60
+
61
+ @command("doctor")
62
+ def cmd_doctor(_args: argparse.Namespace) -> int:
63
+ from mnemo import cli # late binding for monkeypatched _resolve_vault
64
+ from mnemo.install import preflight
65
+
66
+ vault = cli._resolve_vault()
67
+ scope = _detect_install_scope()
68
+ if scope == "project":
69
+ print(f"Install scope: project (local) — {Path.cwd()}")
70
+ elif scope == "global":
71
+ print("Install scope: global — ~/.claude/settings.json")
72
+ elif scope == "both":
73
+ print(f"Install scope: project (local) + global — {Path.cwd()} and ~/.claude/")
74
+ else:
75
+ print("Install scope: not installed")
76
+ print("Running diagnostic / preflight checks…")
77
+ result = preflight.run_preflight(vault_root=vault)
78
+ for issue in result.issues:
79
+ print(f" [{issue.severity}] {issue.kind}: {issue.message}")
80
+ print(f" → {issue.remediation}")
81
+
82
+ all_ok = True
83
+ for _name, check_fn in DOCTOR_CHECKS:
84
+ ok = check_fn(vault)
85
+ # universal_promotion is advisory and effectively always-True today;
86
+ # treat None (or any non-False return) as "not a warning".
87
+ if ok is False:
88
+ all_ok = False
89
+
90
+ _doctor_report_recall(vault)
91
+
92
+ if not result.ok:
93
+ print("Issues found above.")
94
+ return 1
95
+ if not all_ok:
96
+ print("Warnings above.")
97
+ else:
98
+ print("OK")
99
+ return 0
100
+
101
+
102
+ def _doctor_report_recall(vault: Path) -> None:
103
+ """Emit one informational line from `.mnemo/recall-report.json` if present.
104
+
105
+ Purely advisory — never turns doctor into a warning/error, since the recall
106
+ suite is opt-in and the primacy rate is a trend indicator, not a pass/fail
107
+ gate. Silent when the report is missing, malformed, or empty.
108
+ """
109
+ import json as _json
110
+ path = vault / ".mnemo" / "recall-report.json"
111
+ if not path.is_file():
112
+ return
113
+ try:
114
+ data = _json.loads(path.read_text(encoding="utf-8"))
115
+ except (OSError, _json.JSONDecodeError):
116
+ return
117
+ report = (data.get("report") or {}) if isinstance(data, dict) else {}
118
+ cases = report.get("cases", 0) or 0
119
+ rate = report.get("primacy_rate_at_5")
120
+ if cases == 0 or rate is None:
121
+ return
122
+ ts = data.get("generated_at")
123
+ suffix = f" (measured {ts})" if ts else ""
124
+ print(f" ℹ Recall: primacy@5 = {rate:.1%} over {cases} cases{suffix}")
@@ -0,0 +1,30 @@
1
+ """Doctor-check sub-package — one module per concern.
2
+
3
+ Each public ``_doctor_check_*`` function is registered in
4
+ ``cli.commands.doctor.DOCTOR_CHECKS`` (an OCP-compliant
5
+ ``list[tuple[name, callable]]``); adding a new check is a new
6
+ row, not an edit to ``cmd_doctor``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from mnemo.cli.commands.doctor_checks.activation import ( # noqa: F401
11
+ _doctor_check_activation,
12
+ _doctor_check_activation_fidelity,
13
+ )
14
+ from mnemo.cli.commands.doctor_checks.fidelity import ( # noqa: F401
15
+ _doctor_check_zero_hit,
16
+ )
17
+ from mnemo.cli.commands.doctor_checks.misc import ( # noqa: F401
18
+ _doctor_check_auto_brain,
19
+ _doctor_check_legacy_wiki_dirs,
20
+ )
21
+ from mnemo.cli.commands.doctor_checks.reflex import ( # noqa: F401
22
+ _doctor_check_reflex_bilingual_gap,
23
+ _doctor_check_reflex_index,
24
+ _doctor_check_reflex_session_cap_hits,
25
+ _doctor_check_statusline_drift,
26
+ )
27
+ from mnemo.cli.commands.doctor_checks.rules import ( # noqa: F401
28
+ _doctor_check_rule_integrity,
29
+ _doctor_check_universal_promotion,
30
+ )
@@ -0,0 +1,211 @@
1
+ """Activation-related doctor checks.
2
+
3
+ Hosts :func:`_doctor_check_activation` (malformed/stale/over-broad
4
+ activation blocks) and :func:`_doctor_check_activation_fidelity`
5
+ (positive round-trip self-check that every enforce/activates_on rule
6
+ reaches the index and self-activates on a synthesized path).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from mnemo.cli._helpers import _synthesize_path_for_glob
13
+
14
+
15
+ def _doctor_check_activation_fidelity(vault: Path) -> bool:
16
+ """Positive self-check: every enforce/activates_on rule reaches the index
17
+ and its globs actually match a synthesized path.
18
+
19
+ Complements `_doctor_check_activation` (which validates malformed blocks)
20
+ with a round-trip: if the frontmatter parses but the slug never enters the
21
+ index or never self-activates on a representative path, something broke
22
+ between authoring and dispatch.
23
+
24
+ Returns True iff no warnings were emitted. `ℹ` info lines (for rules whose
25
+ globs are un-synthesizable) are NOT warnings.
26
+ """
27
+ from mnemo.core.filters import derive_rule_slug, parse_frontmatter
28
+ from mnemo.core.rule_activation import (
29
+ load_index,
30
+ match_path_enrich,
31
+ parse_activates_on_block,
32
+ parse_enforce_block,
33
+ )
34
+
35
+ index = load_index(vault)
36
+ if index is None:
37
+ return True # no index yet; _doctor_check_activation surfaces staleness
38
+
39
+ indexed_slugs: set[str] = {
40
+ slug for slug, rule in index.get("rules", {}).items()
41
+ if rule.get("enforce") or rule.get("activates_on")
42
+ }
43
+
44
+ ok = True
45
+
46
+ feedback_dir = vault / "shared" / "feedback"
47
+ if feedback_dir.is_dir():
48
+ for md_path in sorted(feedback_dir.glob("*.md")):
49
+ try:
50
+ text = md_path.read_text(encoding="utf-8", errors="replace")
51
+ except OSError:
52
+ continue
53
+ try:
54
+ fm = parse_frontmatter(text)
55
+ except Exception:
56
+ continue
57
+
58
+ slug = derive_rule_slug(fm, md_path.stem)
59
+ rel = md_path.name
60
+
61
+ _parsed_enforce, _enforce_err = parse_enforce_block(fm)
62
+ has_enforce = _parsed_enforce is not None
63
+ has_enrich = parse_activates_on_block(fm) is not None
64
+
65
+ if has_enforce and slug not in indexed_slugs:
66
+ print(f" \u26a0 Rule {rel!r} has a valid enforce block but is absent from the activation index")
67
+ print(f" \u2192 run 'mnemo extract' to rebuild the index")
68
+ ok = False
69
+ if has_enrich and slug not in indexed_slugs:
70
+ print(f" \u26a0 Rule {rel!r} has a valid activates_on block but is absent from the activation index")
71
+ print(f" \u2192 run 'mnemo extract' to rebuild the index")
72
+ ok = False
73
+
74
+ for slug, rule_entry in index.get("rules", {}).items():
75
+ activates = rule_entry.get("activates_on")
76
+ if not activates:
77
+ continue
78
+ # Use first associated project for self-activation test; universal rules
79
+ # are reachable from any project so we just pick one from by_project.
80
+ rule_projects = rule_entry.get("projects", [])
81
+ project = rule_projects[0] if rule_projects else ""
82
+ globs = activates.get("path_globs", []) or []
83
+ tools = activates.get("tools", []) or []
84
+ if not globs or not tools:
85
+ continue
86
+ any_testable = False
87
+ mismatched = False
88
+ for glob in globs:
89
+ sample = _synthesize_path_for_glob(glob)
90
+ if sample is None:
91
+ continue
92
+ any_testable = True
93
+ for tool in tools:
94
+ hits = match_path_enrich(index, project, sample, tool)
95
+ if slug not in [h.slug for h in hits]:
96
+ print(f" \u26a0 Rule {slug!r} does not self-activate: glob {glob!r} -> synthesized {sample!r}, tool {tool!r} returned no hit")
97
+ print(f" \u2192 review the glob shape or the enrich build pipeline")
98
+ ok = False
99
+ mismatched = True
100
+ break
101
+ if mismatched:
102
+ break
103
+ if not any_testable and not mismatched:
104
+ print(f" \u2139 Rule {slug!r} has no auto-testable path_globs (contains '?' or '[abc]' \u2014 manual verification required)")
105
+
106
+ return ok
107
+
108
+
109
+ def _doctor_check_activation(vault: Path) -> bool:
110
+ """Four activation-related doctor checks:
111
+
112
+ 1. Malformed activate/enforce blocks in feedback files.
113
+ 2. Stale activation index (index mtime older than newest feedback file mtime).
114
+ 3. Suspicious deny_pattern (< 5 chars, or matches "echo hello").
115
+ 4. Overly-broad activates_on.path_globs (**/* or *).
116
+
117
+ Each check is fail-safe: a bad file is skipped, not a crash.
118
+ Returns True if no warnings were emitted.
119
+ """
120
+ import re as _re
121
+ from mnemo.core.filters import parse_frontmatter
122
+ from mnemo.core.rule_activation import parse_block
123
+
124
+ feedback_dir = vault / "shared" / "feedback"
125
+ ok = True
126
+
127
+ if not feedback_dir.is_dir():
128
+ return True
129
+
130
+ candidates = sorted(feedback_dir.glob("*.md"))
131
+ newest_mtime: float = 0.0
132
+ for md_path in candidates:
133
+ try:
134
+ mtime = md_path.stat().st_mtime
135
+ if mtime > newest_mtime:
136
+ newest_mtime = mtime
137
+ except OSError:
138
+ pass
139
+
140
+ # --- Check 1: malformed blocks ---
141
+ # --- Check 3: suspicious deny_pattern ---
142
+ # --- Check 4: overly-broad path_globs ---
143
+ _BENIGN_TEST_INPUT = "echo hello"
144
+ _BROAD_GLOBS = {"**/*", "*"}
145
+
146
+ for md_path in candidates:
147
+ try:
148
+ text = md_path.read_text(encoding="utf-8", errors="replace")
149
+ except OSError:
150
+ continue # skip unreadable files
151
+
152
+ try:
153
+ fm = parse_frontmatter(text)
154
+ except Exception:
155
+ continue
156
+
157
+ rel = md_path.name
158
+
159
+ # --- Check 1: enforce block present-but-invalid ---
160
+ if fm.get("enforce") is not None:
161
+ parsed, err = parse_block("enforce", fm)
162
+ if parsed is None:
163
+ print(f" \u26a0 Malformed enforce block in {rel}: {err}")
164
+ print(f" \u2192 fix the frontmatter in shared/feedback/{rel}")
165
+ ok = False
166
+ else:
167
+ # --- Check 3: suspicious deny_pattern ---
168
+ for pattern in parsed.get("deny_patterns", []):
169
+ suspicious = False
170
+ reason = ""
171
+ if len(pattern) < 5:
172
+ suspicious = True
173
+ reason = f"pattern {pattern!r} is shorter than 5 characters"
174
+ elif _re.search(pattern, _BENIGN_TEST_INPUT, _re.IGNORECASE | _re.DOTALL):
175
+ suspicious = True
176
+ reason = f"pattern {pattern!r} matches benign input {_BENIGN_TEST_INPUT!r} — too permissive"
177
+ if suspicious:
178
+ print(f" \u26a0 Suspicious deny_pattern in {rel}: {reason}")
179
+ print(f" \u2192 tighten the pattern so it doesn't match safe commands")
180
+ ok = False
181
+
182
+ # --- Check 1: activates_on block present-but-invalid ---
183
+ if fm.get("activates_on") is not None:
184
+ parsed_enrich, err = parse_block("activates_on", fm)
185
+ if parsed_enrich is None:
186
+ print(f" \u26a0 Malformed activates_on block in {rel}: {err}")
187
+ print(f" \u2192 fix the frontmatter in shared/feedback/{rel}")
188
+ ok = False
189
+ else:
190
+ # --- Check 4: overly-broad path_globs ---
191
+ for glob in parsed_enrich.get("path_globs", []):
192
+ if glob in _BROAD_GLOBS:
193
+ print(f" \u26a0 Overly-broad path_glob {glob!r} in {rel}: matches virtually every file")
194
+ print(f" \u2192 narrow the glob (e.g. **/*.py, src/**/*.ts) to avoid false positives")
195
+ ok = False
196
+
197
+ # --- Check 2: stale activation index ---
198
+ index_path = vault / ".mnemo" / "rule-activation-index.json"
199
+ if index_path.exists() and newest_mtime > 0:
200
+ try:
201
+ index_mtime = index_path.stat().st_mtime
202
+ if index_mtime < newest_mtime:
203
+ import datetime as _dt
204
+ def _fmt(ts: float) -> str:
205
+ return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%dT%H:%M:%S")
206
+ print(f" \u26a0 Activation index is stale (newest feedback file: {_fmt(newest_mtime)}, index: {_fmt(index_mtime)}). Run 'mnemo extract' to rebuild.")
207
+ ok = False
208
+ except OSError:
209
+ pass
210
+
211
+ return ok