1"""PostToolUse install-nudge gate for Bash tool calls (ENH-2212).

2

3 When a Bash command contains a package-manager install invocation (pip, uv,

4 poetry, npm, yarn, pnpm), extracts the package name, queries the Learning Test

5 Registry, and emits a nudge via ``/ll:explore-api`` if no proven record exists

6 or the existing record is stale.

7

8 Gated behind ``learning_tests.enabled`` in ``.ll/ll-config.json``. When

9 disabled (default), the handler is a no-op — no registry I/O occurs.

10

11 Supported install commands:

12 pip install <pkg> pip3 install <pkg>

13 uv add <pkg> poetry add <pkg>

14 npm install <pkg> yarn add <pkg> pnpm add <pkg>

15

16 Out of scope (silently skipped):

17 pip install -r requirements.txt (flag-prefixed, no single package name)

18 Transitive or bulk installs (only the first explicit package token)

19"""

20

21 from __future__ import annotations

22

23 import json

24 import re

25 from pathlib import Path

26

27 from little_loops.config.core import resolve_config_path

28 from little_loops.config.features import LearningTestsConfig

29 from little_loops.hooks.types import LLHookEvent, LLHookResult

30 from little_loops.learning_tests import check_learning_test

31 from little_loops.learning_tests.gate import format_nudge_message, is_record_stale

32

33# Matches pip/pip3/uv/poetry/npm/yarn/pnpm install/add followed by the first

34# non-flag package token. The negative lookahead (?!-) rejects flag tokens

35# like -r, --save-dev, etc., causing the whole match to fail when the first

36# argument is a flag (e.g. pip install -r requirements.txt → no match).

37_INSTALL_RE = re.compile(

38 r"\b(?:pip3?\s+install|uv\s+add|poetry\s+add|npm\s+install|yarn\s+add|pnpm\s+add)"

39 r"\s+((?!-)\S+)",

40 re.IGNORECASE,

41)

42

43# Session-level cache: normalized package name → True (proven+fresh) / False.

44# Avoids repeated registry reads when the same package is installed multiple

45# times in one session.

46_SESSION_CACHE: dict[str, bool] = {}

47

48

49 def _load_lt_config(cwd: Path) -> LearningTestsConfig:

50 """Load LearningTestsConfig from project config, returning defaults on miss."""

51 config_path = resolve_config_path(cwd)

52 if config_path is None:

53 return LearningTestsConfig()

54 try:

55 data = json.loads(config_path.read_text(encoding="utf-8"))

56 except (OSError, json.JSONDecodeError):

57 return LearningTestsConfig()

58 if not isinstance(data, dict):

59 return LearningTestsConfig()

60 return LearningTestsConfig.from_dict(data.get("learning_tests", {}))

61

62

63 def _normalize_pkg(raw: str) -> str | None:

64 """Normalize a raw package token from the install command.

65

66 Strips surrounding quotes, extras (``[...]``), and version specifiers

67 (``>=``, ``==``, ``~=``, ``!=``, ``<=``, ``<``, ``>``). Returns ``None``

68 for flags (``-r``, ``--requirement``), empty strings, or tokens that

69 resolve to empty after stripping.

70 """

71 if not raw:

72 return None

73 # Flags (should be filtered by regex, but defensive)

74 if raw.startswith("-"):

75 return None

76 # Strip surrounding quotes

77 raw = raw.strip("\"'")

78 if not raw or raw.startswith("-"):

79 return None

80 # Strip extras: anthropic[bedrock] → anthropic

81 raw = re.sub(r"\[.*?\]", "", raw)

82 # Strip version specifiers: requests>=2.0 → requests

83 raw = re.sub(r"[><=!~][^\s]*$", "", raw)

84 raw = raw.strip()

85 return raw if raw else None

86

87

88 def gate(event: LLHookEvent) -> LLHookResult:

89 """Check Bash install commands against the Learning Test Registry.

90

91 Returns a nudge (exit_code=0, feedback=...) when the installed package has

92 no proven record or the existing record is stale. Silent pass otherwise.

93 """

94 cwd = Path(event.cwd) if event.cwd else Path.cwd()

95 lt_config = _load_lt_config(cwd)

96

97 if not lt_config.enabled:

98 return LLHookResult(exit_code=0)

99

100 payload = event.payload or {}

101 tool_input = payload.get("tool_input", {}) or {}

102 cmd = tool_input.get("command") or ""

103

104 m = _INSTALL_RE.search(cmd)

105 if not m:

106 return LLHookResult(exit_code=0)

107

108 pkg = _normalize_pkg(m.group(1))

109 if not pkg:

110 return LLHookResult(exit_code=0)

111

112 # Session cache hit

113 if pkg in _SESSION_CACHE:

114 if _SESSION_CACHE[pkg]:

115 return LLHookResult(exit_code=0)

116 return LLHookResult(exit_code=0, feedback=format_nudge_message(pkg, stale=False))

117

118 base_dir = cwd / ".ll" / "learning-tests"

119 record = check_learning_test(pkg, base_dir=base_dir)

120

121 if record is not None and record.status == "proven":

122 if is_record_stale(record, lt_config.stale_after_days):

123 _SESSION_CACHE[pkg] = False

124 return LLHookResult(exit_code=0, feedback=format_nudge_message(pkg, stale=True))

125 _SESSION_CACHE[pkg] = True

126 return LLHookResult(exit_code=0)

127

128 _SESSION_CACHE[pkg] = False

129 return LLHookResult(exit_code=0, feedback=format_nudge_message(pkg, stale=False))