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.
- mnemo/__init__.py +1 -0
- mnemo/__main__.py +4 -0
- mnemo/cli/__init__.py +31 -0
- mnemo/cli/_helpers/__init__.py +80 -0
- mnemo/cli/commands/__init__.py +25 -0
- mnemo/cli/commands/briefing.py +35 -0
- mnemo/cli/commands/dedup_rules.py +44 -0
- mnemo/cli/commands/disable_rule.py +62 -0
- mnemo/cli/commands/doctor.py +124 -0
- mnemo/cli/commands/doctor_checks/__init__.py +30 -0
- mnemo/cli/commands/doctor_checks/activation.py +211 -0
- mnemo/cli/commands/doctor_checks/fidelity.py +38 -0
- mnemo/cli/commands/doctor_checks/misc.py +92 -0
- mnemo/cli/commands/doctor_checks/orphan_worktree_briefings.py +85 -0
- mnemo/cli/commands/doctor_checks/reflex.py +189 -0
- mnemo/cli/commands/doctor_checks/rules.py +179 -0
- mnemo/cli/commands/extract.py +88 -0
- mnemo/cli/commands/init.py +204 -0
- mnemo/cli/commands/list_enforced.py +56 -0
- mnemo/cli/commands/migrate_worktree_briefings.py +90 -0
- mnemo/cli/commands/misc.py +87 -0
- mnemo/cli/commands/recall.py +79 -0
- mnemo/cli/commands/status.py +224 -0
- mnemo/cli/commands/statusline.py +34 -0
- mnemo/cli/commands/telemetry.py +24 -0
- mnemo/cli/parser.py +102 -0
- mnemo/cli/runtime.py +47 -0
- mnemo/core/__init__.py +0 -0
- mnemo/core/agent.py +111 -0
- mnemo/core/briefing.py +234 -0
- mnemo/core/config.py +138 -0
- mnemo/core/dashboard.py +200 -0
- mnemo/core/dedup_rules.py +169 -0
- mnemo/core/errors.py +126 -0
- mnemo/core/extract/__init__.py +542 -0
- mnemo/core/extract/inbox/__init__.py +75 -0
- mnemo/core/extract/inbox/apply.py +160 -0
- mnemo/core/extract/inbox/branches/__init__.py +14 -0
- mnemo/core/extract/inbox/branches/auto_promoted.py +163 -0
- mnemo/core/extract/inbox/branches/inbox_flow.py +180 -0
- mnemo/core/extract/inbox/branches/upgrade.py +41 -0
- mnemo/core/extract/inbox/dedup.py +218 -0
- mnemo/core/extract/inbox/io.py +86 -0
- mnemo/core/extract/inbox/paths.py +66 -0
- mnemo/core/extract/inbox/rendering.py +157 -0
- mnemo/core/extract/inbox/state_io.py +94 -0
- mnemo/core/extract/inbox/types.py +46 -0
- mnemo/core/extract/promote.py +100 -0
- mnemo/core/extract/prompts/__init__.py +42 -0
- mnemo/core/extract/prompts/encoding.py +30 -0
- mnemo/core/extract/prompts/render.py +127 -0
- mnemo/core/extract/prompts/templates/__init__.py +7 -0
- mnemo/core/extract/prompts/templates/briefing.py +50 -0
- mnemo/core/extract/prompts/templates/few_shot_feedback.py +167 -0
- mnemo/core/extract/prompts/templates/few_shot_simple.py +44 -0
- mnemo/core/extract/prompts/templates/schema.py +48 -0
- mnemo/core/extract/prompts/templates/system_feedback.py +105 -0
- mnemo/core/extract/prompts/templates/system_simple.py +33 -0
- mnemo/core/extract/prompts/vault_tags.py +33 -0
- mnemo/core/extract/scanner.py +215 -0
- mnemo/core/filters.py +229 -0
- mnemo/core/llm.py +184 -0
- mnemo/core/locks.py +44 -0
- mnemo/core/log_utils.py +15 -0
- mnemo/core/log_writer.py +70 -0
- mnemo/core/mcp/__init__.py +6 -0
- mnemo/core/mcp/access_log.py +113 -0
- mnemo/core/mcp/access_log_summary.py +220 -0
- mnemo/core/mcp/recall.py +346 -0
- mnemo/core/mcp/server.py +231 -0
- mnemo/core/mcp/session_state.py +212 -0
- mnemo/core/mcp/tools.py +291 -0
- mnemo/core/mirror.py +103 -0
- mnemo/core/paths.py +44 -0
- mnemo/core/pricing.py +36 -0
- mnemo/core/reflex/__init__.py +1 -0
- mnemo/core/reflex/bm25.py +100 -0
- mnemo/core/reflex/gates.py +74 -0
- mnemo/core/reflex/index.py +159 -0
- mnemo/core/reflex/stopwords.py +42 -0
- mnemo/core/reflex/tokenizer.py +44 -0
- mnemo/core/rule_activation/__init__.py +53 -0
- mnemo/core/rule_activation/activity_log.py +85 -0
- mnemo/core/rule_activation/globs.py +113 -0
- mnemo/core/rule_activation/index.py +316 -0
- mnemo/core/rule_activation/matching.py +232 -0
- mnemo/core/rule_activation/parsing.py +263 -0
- mnemo/core/session.py +66 -0
- mnemo/core/text_utils.py +27 -0
- mnemo/core/transcript.py +54 -0
- mnemo/hooks/__init__.py +0 -0
- mnemo/hooks/pre_tool_use.py +161 -0
- mnemo/hooks/session_end.py +304 -0
- mnemo/hooks/session_start.py +241 -0
- mnemo/hooks/user_prompt_submit.py +209 -0
- mnemo/install/__init__.py +0 -0
- mnemo/install/preflight.py +105 -0
- mnemo/install/scaffold.py +48 -0
- mnemo/install/settings.py +451 -0
- mnemo/statusline.py +319 -0
- mnemo/templates/HOME.md +53 -0
- mnemo/templates/README.md +34 -0
- mnemo/templates/__init__.py +0 -0
- mnemo/templates/graph-dark-gold.css +16 -0
- mnemo/templates/mnemo.config.json +10 -0
- mnemo_claude-0.12.0.dist-info/METADATA +542 -0
- mnemo_claude-0.12.0.dist-info/RECORD +111 -0
- mnemo_claude-0.12.0.dist-info/WHEEL +5 -0
- mnemo_claude-0.12.0.dist-info/entry_points.txt +2 -0
- mnemo_claude-0.12.0.dist-info/licenses/LICENSE +21 -0
- 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
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
|