@miller-tech/uap 1.81.0 → 1.82.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.81.0",
3
+ "version": "1.82.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -46,10 +46,24 @@ def main() -> None:
46
46
  if os.environ.get("UAP_NO_WORKTREE") == "1":
47
47
  emit(True, "UAP_NO_WORKTREE override set")
48
48
 
49
+ # R1: a worktree cannot exist without git, so the requirement is
50
+ # UNSATISFIABLE on a non-git project -- blocking there is a guaranteed
51
+ # deadlock (the model loops creating tasks about a worktree it can never
52
+ # create). Fail open when there is no git metadata.
53
+ if not os.path.exists(os.path.join(str(root), ".git")):
54
+ emit(True, "not a git repo -- worktrees are not applicable")
55
+
56
+ # R3: imperative message with a working fallback command + a machine-
57
+ # actionable route hint. The old message pointed only at uap worktree
58
+ # create, which fails on hybrid/bare repos.
49
59
  emit(
50
60
  False,
51
- f"worktree-required: '{rel}' must be edited inside .worktrees/NNN-<slug>/. "
52
- "Run: uap worktree create <slug>",
61
+ f"BLOCKED: '{rel}' must be edited inside a worktree (.worktrees/NNN-<slug>/). "
62
+ "Create one FIRST, then edit there: `uap worktree create <slug>` "
63
+ "(or if that fails: `git worktree add .worktrees/001-<slug> -b feature/<slug>`). "
64
+ "Do NOT keep planning or creating tasks about the worktree -- run the command now.",
65
+ route="worktree",
66
+ worktreeHint="uap worktree create <slug>",
53
67
  )
54
68
 
55
69
 
@@ -206,6 +206,16 @@ PROXY_COORDINATION_TOOLS = {
206
206
  PROXY_COORDINATION_BAN_THRESHOLD = int(
207
207
  os.environ.get("PROXY_COORDINATION_BAN_THRESHOLD", "2")
208
208
  )
209
+ # R4: early coordination-loop ban. The per-tool cycle ban above engages only
210
+ # inside an active agentic loop (tool_results present). But a model can loop on a
211
+ # coordination tool from the very FIRST moves -- e.g. repeatedly creating a task
212
+ # ABOUT doing X instead of doing X (observed: TaskCreate x4 identical args at
213
+ # session start) -- where the cycle detector never engages. This bans a
214
+ # coordination tool after N consecutive IDENTICAL calls regardless of loop state.
215
+ # 0 disables.
216
+ PROXY_COORDINATION_EARLY_BAN = int(
217
+ os.environ.get("PROXY_COORDINATION_EARLY_BAN", "3")
218
+ )
209
219
  # Force finalize after N consecutive forced_budget_exhausted events where
210
220
  # neither cycling nor stagnation was detected — catches "distinct but
211
221
  # unproductive" tool spam that defeats per-tool cycle detection.
@@ -1036,6 +1046,8 @@ class SessionMonitor:
1036
1046
  tool_state_unproductive_exhaustion_streak: int = 0
1037
1047
  last_tool_fingerprint: str = ""
1038
1048
  cycling_tool_names: list = field(default_factory=list)
1049
+ coordination_repeat_streak: int = 0 # R4: consecutive identical coordination-tool calls
1050
+ last_coordination_fp: str = "" # R4: fingerprint of the last coordination call
1039
1051
  session_banned_tools: set = field(default_factory=set) # tools banned for entire session after repeated cycling
1040
1052
  tool_cycle_counts: dict = field(default_factory=dict) # {tool_name: cycle_count} across resets
1041
1053
  last_response_garbled: bool = False # previous turn had garbled/malformed output
@@ -1201,6 +1213,33 @@ class SessionMonitor:
1201
1213
  if len(self.tool_call_history) > 30:
1202
1214
  self.tool_call_history = self.tool_call_history[-30:]
1203
1215
 
1216
+ # R4: early coordination-loop ban (independent of the active-loop cycle
1217
+ # detector). A coordination/bookkeeping tool repeated with IDENTICAL args
1218
+ # is never productive; ban it directly so narrowing drops it next turn,
1219
+ # breaking the "create a task ABOUT X instead of doing X" loop.
1220
+ coord_names = [n for n in (tool_names or []) if n in PROXY_COORDINATION_TOOLS]
1221
+ if fp and coord_names and fp == self.last_coordination_fp:
1222
+ self.coordination_repeat_streak += 1
1223
+ elif coord_names:
1224
+ self.coordination_repeat_streak = 1
1225
+ self.last_coordination_fp = fp
1226
+ else:
1227
+ self.coordination_repeat_streak = 0
1228
+ self.last_coordination_fp = ""
1229
+ if (
1230
+ PROXY_COORDINATION_EARLY_BAN > 0
1231
+ and self.coordination_repeat_streak >= PROXY_COORDINATION_EARLY_BAN
1232
+ ):
1233
+ for n in coord_names:
1234
+ if n not in self.session_banned_tools:
1235
+ self.session_banned_tools.add(n)
1236
+ logger.warning(
1237
+ "TOOL BAN (R4 early): '%s' banned after %d consecutive "
1238
+ "identical coordination calls",
1239
+ n,
1240
+ self.coordination_repeat_streak,
1241
+ )
1242
+
1204
1243
  # Recon-convergence (B1): count consecutive turns that use tools but
1205
1244
  # produce NO write/deliverable tool call. A turn that uses any write
1206
1245
  # tool resets the streak — that's the model converging from
@@ -0,0 +1,43 @@
1
+ """Tests for R4: early coordination-loop ban (independent of active-loop)."""
2
+ import importlib.util
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ proxy_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
7
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
8
+ ap = importlib.util.module_from_spec(spec)
9
+ spec.loader.exec_module(ap)
10
+
11
+
12
+ class EarlyCoordinationBanTest(unittest.TestCase):
13
+ def test_config_default(self):
14
+ self.assertEqual(ap.PROXY_COORDINATION_EARLY_BAN, 3)
15
+
16
+ def test_identical_coordination_repeats_get_banned(self):
17
+ m = ap.SessionMonitor(context_window=132096)
18
+ fp = "TaskCreate:deadbeef"
19
+ for _ in range(3):
20
+ m.record_tool_calls(["TaskCreate"], fingerprint=fp)
21
+ self.assertIn("TaskCreate", m.session_banned_tools)
22
+
23
+ def test_two_repeats_not_yet_banned(self):
24
+ m = ap.SessionMonitor(context_window=132096)
25
+ for _ in range(2):
26
+ m.record_tool_calls(["TaskCreate"], fingerprint="TaskCreate:x")
27
+ self.assertNotIn("TaskCreate", m.session_banned_tools)
28
+
29
+ def test_non_coordination_tool_not_banned(self):
30
+ m = ap.SessionMonitor(context_window=132096)
31
+ for _ in range(5):
32
+ m.record_tool_calls(["Bash"], fingerprint="Bash:same")
33
+ self.assertNotIn("Bash", m.session_banned_tools)
34
+
35
+ def test_varying_args_reset_streak(self):
36
+ m = ap.SessionMonitor(context_window=132096)
37
+ for i in range(5):
38
+ m.record_tool_calls(["TaskCreate"], fingerprint=f"TaskCreate:{i}")
39
+ self.assertNotIn("TaskCreate", m.session_banned_tools)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ unittest.main()
@@ -0,0 +1,63 @@
1
+ """Tests for worktree-required R1 (fail-open on non-git) + R3 (route hint)."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ ENF = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "worktree_required.py"
11
+
12
+
13
+ def run(root, target):
14
+ e = dict(os.environ)
15
+ e["UAP_REPO_ROOT"] = str(root)
16
+ e.pop("UAP_NO_WORKTREE", None)
17
+ p = subprocess.run(
18
+ [sys.executable, str(ENF), "--operation", "Write",
19
+ "--args", json.dumps({"file_path": str(target)})],
20
+ capture_output=True, text=True, env=e,
21
+ )
22
+ out = json.loads(p.stdout) if p.stdout.strip() else {}
23
+ return p.returncode, out
24
+
25
+
26
+ class WorktreeRequiredTest(unittest.TestCase):
27
+ def test_R1_fail_open_on_non_git(self):
28
+ with tempfile.TemporaryDirectory() as td:
29
+ root = Path(td)
30
+ f = root / "foo.ts"
31
+ f.write_text("x")
32
+ rc, out = run(root, f)
33
+ self.assertEqual(rc, 0)
34
+ self.assertTrue(out["allowed"])
35
+ self.assertIn("not a git repo", out["reason"])
36
+
37
+ def test_R3_blocks_with_route_on_git_repo(self):
38
+ with tempfile.TemporaryDirectory() as td:
39
+ root = Path(td)
40
+ (root / ".git").mkdir() # make it look like a git repo
41
+ f = root / "src" / "app.ts"
42
+ f.parent.mkdir(parents=True)
43
+ f.write_text("x")
44
+ rc, out = run(root, f)
45
+ self.assertEqual(rc, 2)
46
+ self.assertFalse(out["allowed"])
47
+ self.assertEqual(out.get("route"), "worktree")
48
+ self.assertIn("worktreeHint", out)
49
+ self.assertIn("run the command now", out["reason"])
50
+
51
+ def test_worktree_path_allowed(self):
52
+ with tempfile.TemporaryDirectory() as td:
53
+ root = Path(td)
54
+ (root / ".git").mkdir()
55
+ f = root / ".worktrees" / "001-x" / "a.ts"
56
+ f.parent.mkdir(parents=True)
57
+ f.write_text("x")
58
+ rc, out = run(root, f)
59
+ self.assertEqual(rc, 0)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ unittest.main()